From eec1bcb6ed0f0fcadf11d9b54bf48aaecac13eb8 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 20:52:36 -0700 Subject: [PATCH 001/129] 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 002/129] 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 003/129] 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 004/129] 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 005/129] 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 006/129] 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 007/129] 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 008/129] 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 009/129] 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 010/129] 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 011/129] 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 012/129] 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 013/129] 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 014/129] 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 015/129] 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 016/129] 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 017/129] 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 018/129] 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 019/129] 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 020/129] 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)" + } } From 5679c9d2f15f04b566f70ae9059f774c5ff71b81 Mon Sep 17 00:00:00 2001 From: James Chang Date: Thu, 21 May 2026 03:05:10 -0700 Subject: [PATCH 021/129] Polish GUI activity and device workflows Harden helper pipe IO and async profile registry persistence. Refine add-device layout, toolbar affordances, activity readiness text, and saved-password wording. Show full Time Capsule model identifiers in discovery results and cover the behavior with tests. --- .../TimeCapsuleSMBApp/ActivityStore.swift | 45 +- .../TimeCapsuleSMBApp/ActivityView.swift | 42 +- .../AddDeviceFlowStore.swift | 32 +- .../Sources/TimeCapsuleSMBApp/AppStore.swift | 34 +- .../ConfiguredDeviceProfileSaver.swift | 12 +- .../ConnectionWorkflowStore.swift | 31 +- .../TimeCapsuleSMBApp/ContentView.swift | 158 +++++-- .../TimeCapsuleSMBApp/DashboardStore.swift | 51 ++- .../DeviceRegistryStore.swift | 409 ++++++++++++------ .../TimeCapsuleSMBApp/HelperPipeReader.swift | 70 +++ .../HelperRequestWriter.swift | 7 + .../TimeCapsuleSMBApp/HelperRunner.swift | 52 +-- .../Resources/en.lproj/Localizable.strings | 6 +- .../ActivityStoreTests.swift | 32 ++ .../AddDeviceFlowStoreTests.swift | 91 +++- .../ConfiguredDeviceProfileSaverTests.swift | 63 ++- .../DashboardStoreTests.swift | 83 ++-- .../DeviceRegistryStoreTests.swift | 106 +++-- src/timecapsulesmb/discovery/devices.py | 16 +- tests/test_discovery_devices.py | 23 + 20 files changed, 979 insertions(+), 384 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperPipeReader.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift index 76ad7441..1ab1fdd0 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift @@ -71,10 +71,16 @@ final class ActivityStore: ObservableObject { 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 isRunning = coordinator.backend.isRunning + let presentation = presentation( + operation: operation, + events: events, + timeline: timeline, + isRunning: isRunning + ) let scope: ActivityScope if let activeDeviceID = coordinator.activeDeviceID { scope = .device(activeDeviceID) @@ -84,15 +90,44 @@ final class ActivityStore: ObservableObject { scope = .unknown } snapshot = ActivitySnapshot( - isRunning: coordinator.backend.isRunning, + isRunning: isRunning, scope: scope, - operationTitle: operation.map(OperationTimelineBuilder.operationTitle) - ?? (timeline.isEmpty ? L10n.string("activity.no_active_operation") : L10n.string("activity.last_operation")), - latestMessage: latestMessage, + operationTitle: presentation.title, + latestMessage: presentation.message, timeline: timeline ) } + private func presentation( + operation: String?, + events: [BackendEvent], + timeline: [OperationTimelineItem], + isRunning: Bool + ) -> (title: String, message: String?) { + if appReadinessPassed(operation: operation, events: events, isRunning: isRunning) { + return (L10n.string("activity.app_ready"), nil) + } + + let title = operation.map(OperationTimelineBuilder.operationTitle) + ?? (timeline.isEmpty ? L10n.string("activity.no_active_operation") : L10n.string("activity.last_operation")) + let message = timeline.last?.detail ?? events.last?.summary + return (title, message) + } + + private func appReadinessPassed(operation: String?, events: [BackendEvent], isRunning: Bool) -> Bool { + guard + !isRunning, + operation == "validate-install", + let latestEvent = events.last, + latestEvent.operation == "validate-install", + latestEvent.type == "result", + latestEvent.ok == true + else { + return false + } + return true + } + private func latestOperation(from events: [BackendEvent]) -> String? { events.last?.operation } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift index 2f5f8f00..d945fdae 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift @@ -6,20 +6,11 @@ struct ActivityCompactView: View { var body: some View { let snapshot = activityStore.snapshot + let hasLatestMessage = hasLatestMessage(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) - } - } + messageView(snapshot, hasLatestMessage: hasLatestMessage) Spacer() if let last = snapshot.timeline.last { Text(last.title) @@ -32,6 +23,28 @@ struct ActivityCompactView: View { .background(Color.secondary.opacity(0.06)) } + @ViewBuilder + private func messageView(_ snapshot: ActivitySnapshot, hasLatestMessage: Bool) -> some View { + if hasLatestMessage { + VStack(alignment: .leading, spacing: 2) { + Text(title(snapshot)) + .font(.caption.weight(.medium)) + .lineLimit(1) + Text(snapshot.latestMessage ?? "") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(height: 30, alignment: .center) + } else { + Text(title(snapshot)) + .font(.caption.weight(.medium)) + .lineLimit(1) + .frame(height: 30, alignment: .center) + } + } + private func title(_ snapshot: ActivitySnapshot) -> String { if case .device(let activeDeviceID) = snapshot.scope, let profile = registry.profile(id: activeDeviceID) { @@ -39,4 +52,11 @@ struct ActivityCompactView: View { } return snapshot.operationTitle } + + private func hasLatestMessage(_ snapshot: ActivitySnapshot) -> Bool { + guard let latestMessage = snapshot.latestMessage else { + return false + } + return !latestMessage.isEmpty + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift index fdb0818d..9694aa14 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift @@ -381,20 +381,24 @@ final class AddDeviceFlowStore: ObservableObject { return } - do { - state = .savingProfile - let profileID = pendingProfileID ?? UUID().uuidString.lowercased() - savedProfile = try profileSaver.saveConfiguredDevice( - configuredDevice: configured, - discoveredDevice: pendingDiscoveredDevice, - password: password, - preferredID: profileID - ) - error = nil - state = .saved - activeOperation = nil - } catch { - failProfileSave(error) + state = .savingProfile + let profileID = pendingProfileID ?? UUID().uuidString.lowercased() + let pendingDiscoveredDevice = pendingDiscoveredDevice + let password = password + Task { @MainActor in + do { + savedProfile = try await profileSaver.saveConfiguredDevice( + configuredDevice: configured, + discoveredDevice: pendingDiscoveredDevice, + password: password, + preferredID: profileID + ) + error = nil + state = .saved + activeOperation = nil + } catch { + failProfileSave(error) + } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift index e8dd93db..cd0c922b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift @@ -92,9 +92,9 @@ final class AppStore: ObservableObject { operationCoordinator.backend } - func start() { - deviceRegistry.load() - refreshPasswordStates() + func start() async { + await deviceRegistry.load() + await refreshPasswordStates() appReadinessStore.start() } @@ -136,49 +136,49 @@ final class AppStore: ObservableObject { do { return try passwordStore.password(for: profile.keychainAccount) } catch PasswordStoreError.missing { - deviceRegistry.updatePasswordState(.missing, for: profile.id) + Task { await deviceRegistry.updatePasswordState(.missing, for: profile.id) } return nil } catch { - deviceRegistry.updatePasswordState(.keychainUnavailable, for: profile.id) + Task { await deviceRegistry.updatePasswordState(.keychainUnavailable, for: profile.id) } return nil } } - func savePassword(_ password: String, for profile: DeviceProfile) throws { + func savePassword(_ password: String, for profile: DeviceProfile) async throws { try passwordStore.save(password, for: profile.keychainAccount) - deviceRegistry.updatePasswordState(.available, for: profile.id) + await deviceRegistry.updatePasswordState(.available, for: profile.id) } - func updateSettings(_ settings: DeviceProfileSettings, for profile: DeviceProfile) throws { + func updateSettings(_ settings: DeviceProfileSettings, for profile: DeviceProfile) async throws { var updated = profile updated.settings = settings - try deviceRegistry.updateProfile(updated) + try await deviceRegistry.updateProfile(updated) } - func rename(_ profile: DeviceProfile, displayName: String) throws { + func rename(_ profile: DeviceProfile, displayName: String) async throws { var updated = profile updated.displayName = displayName - try deviceRegistry.updateProfile(updated) + try await deviceRegistry.updateProfile(updated) } - func updateHost(_ profile: DeviceProfile, host: String) throws { + func updateHost(_ profile: DeviceProfile, host: String) async throws { var updated = profile updated.host = host - try deviceRegistry.updateProfile(updated) + try await deviceRegistry.updateProfile(updated) } - func forget(_ profile: DeviceProfile) throws { + func forget(_ profile: DeviceProfile) async throws { try passwordStore.deletePassword(for: profile.keychainAccount) - try deviceRegistry.delete(profile) + try await deviceRegistry.delete(profile) if selectedDeviceID == profile.id { selectedDeviceID = deviceRegistry.profiles.first?.id showingAddDevice = false } } - func refreshPasswordStates() { + func refreshPasswordStates() async { for profile in deviceRegistry.profiles { - deviceRegistry.updatePasswordState(effectivePasswordState(for: profile), for: profile.id) + await deviceRegistry.updatePasswordState(effectivePasswordState(for: profile), for: profile.id) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift index 647e0bdd..768d6768 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift @@ -7,7 +7,7 @@ protocol ConfiguredDeviceProfileSaving: AnyObject { discoveredDevice: DiscoveredDevice?, password: String, preferredID: DeviceProfile.ID - ) throws -> DeviceProfile + ) async throws -> DeviceProfile } @MainActor @@ -30,8 +30,8 @@ final class ConfiguredDeviceProfileSaver: ConfiguredDeviceProfileSaving { discoveredDevice: DiscoveredDevice?, password: String, preferredID: DeviceProfile.ID - ) throws -> DeviceProfile { - let profile = registry.makeConfiguredDeviceProfile( + ) async throws -> DeviceProfile { + let profile = await registry.makeConfiguredDeviceProfile( configuredDevice: configuredDevice, discoveredDevice: discoveredDevice, passwordState: .available, @@ -44,17 +44,17 @@ final class ConfiguredDeviceProfileSaver: ConfiguredDeviceProfileSaving { try passwordStore.save(password, for: profile.keychainAccount) } catch { if !wasSavedProfile { - registry.discardArtifacts(for: profile) + await registry.discardArtifacts(for: profile) } throw error } do { - return try registry.saveProfileMergingDuplicates(profile) + return try await registry.saveProfileMergingDuplicates(profile) } catch { rollbackPassword(rollback, account: profile.keychainAccount) if !wasSavedProfile { - registry.discardArtifacts(for: profile) + await registry.discardArtifacts(for: profile) } throw error } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift index ec47013a..63fa38f8 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift @@ -49,8 +49,8 @@ struct DiscoveredDevice: Identifiable, Equatable { 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.syap = Self.nonEmpty(payload.syap) + self.model = Self.nonEmpty(payload.model) ?? Self.recordProperty(payload.selectedRecord, keys: ["model", "am"]) self.rawRecord = payload.selectedRecord } @@ -73,17 +73,40 @@ struct DiscoveredDevice: Identifiable, Equatable { 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.syap = Self.nonEmpty(record.properties["syAP"] ?? record.properties["syap"]) + self.model = Self.nonEmpty(record.properties["model"] ?? record.properties["am"]) self.rawRecord = record.jsonValue } + var discoveryModelText: String { + Self.nonEmpty(model) ?? "" + } + private static func displayHost(_ record: BonjourResolvedServicePayload) -> String { if let address = record.ipv4.first ?? record.ipv6.first { return address } return record.hostname } + + private static func recordProperty(_ record: JSONValue, keys: [String]) -> String? { + guard case .object(let values) = record, case .object(let properties)? = values["properties"] else { + return nil + } + for key in keys { + if case .string(let value)? = properties[key], let trimmed = nonEmpty(value) { + return trimmed + } + } + return nil + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } } struct ConfiguredDeviceState: Equatable { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 26cd9376..be62a076 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -45,38 +45,41 @@ public struct ContentView: View { } .toolbar { ToolbarItemGroup { - Button { + ToolbarIconButton( + title: L10n.string("toolbar.add"), + systemImage: "plus" + ) { appStore.showAddDevice() - } label: { - Label(L10n.string("toolbar.add"), systemImage: "plus") } - Button { + ToolbarIconButton( + title: L10n.string("toolbar.diagnostics"), + systemImage: "wrench.and.screwdriver" + ) { diagnosticsPresented = true - } label: { - Label(L10n.string("toolbar.diagnostics"), systemImage: "wrench.and.screwdriver") } - Button { - if let profile = appStore.selectedProfile { - profilePendingDeletion = profile - } else { - appStore.operationCoordinator.clear() + ToolbarIconButton( + title: L10n.string("toolbar.forget"), + systemImage: "trash", + disabled: appStore.selectedProfile == nil || appStore.backend.isRunning + ) { + guard let profile = appStore.selectedProfile else { + return } - } label: { - Label(appStore.selectedProfile == nil ? L10n.string("toolbar.clear") : L10n.string("toolbar.forget"), systemImage: "trash") + profilePendingDeletion = profile } - .disabled(appStore.backend.isRunning) - Button { + ToolbarIconButton( + title: L10n.string("toolbar.cancel"), + systemImage: "xmark.circle", + disabled: !appStore.backend.canCancel + ) { appStore.operationCoordinator.cancel() - } label: { - Label(L10n.string("toolbar.cancel"), systemImage: "xmark.circle") } - .disabled(!appStore.backend.canCancel) } } } .frame(minWidth: 1080, minHeight: 720) .task { - appStore.start() + await appStore.start() } .onChange(of: addDeviceStore.savedProfile) { profile in guard let profile else { return } @@ -98,11 +101,13 @@ public struct ContentView: View { presenting: profilePendingDeletion ) { profile in Button(L10n.format("dialog.forget.action", profile.title), role: .destructive) { - do { - try appStore.forget(profile) - profilePendingDeletion = nil - } catch { - deleteErrorMessage = error.localizedDescription + Task { @MainActor in + do { + try await appStore.forget(profile) + profilePendingDeletion = nil + } catch { + deleteErrorMessage = error.localizedDescription + } } } Button(L10n.string("action.cancel"), role: .cancel) { @@ -239,6 +244,41 @@ public struct ContentView: View { } } +private struct ToolbarIconButton: View { + let title: String + let systemImage: String + var disabled = false + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button { + guard !disabled else { + return + } + action() + } label: { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(disabled ? Color.secondary.opacity(0.5) : Color.primary) + .frame(width: 28, height: 28) + .background { + Circle() + .fill(isHovered && !disabled ? Color.primary.opacity(0.10) : Color.clear) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(title) + .accessibilityLabel(title) + .accessibilityValue(disabled ? L10n.string("toolbar.disabled") : "") + .onHover { hovering in + isHovered = hovering + } + } +} + private struct DeviceListOverviewView: View { @ObservedObject var appStore: AppStore @@ -288,6 +328,21 @@ private struct AddDeviceView: View { @ObservedObject var store: AddDeviceFlowStore var body: some View { + VStack(alignment: .leading, spacing: 14) { + topSection + if store.entryMode == .manual { + connectionControls + Spacer(minLength: 0) + } else { + deviceResultsSection + connectionControls + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var topSection: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { Text(L10n.string("add_device.title")) @@ -321,21 +376,41 @@ private struct AddDeviceView: View { } .frame(minHeight: 28, alignment: .center) + } + } + + private var deviceResultsSection: some View { + Group { if store.entryMode == .discover && !store.devices.isEmpty { VStack(alignment: .leading, spacing: 6) { Text(L10n.string("add_device.discovered_devices")) .font(.headline) - ForEach(store.devices) { device in - Button { - store.select(device) - } label: { - DeviceCandidateRow(device: device, selected: store.selectedDeviceID == device.id) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(store.devices) { device in + Button { + store.select(device) + } label: { + DeviceCandidateRow(device: device, selected: store.selectedDeviceID == device.id) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } } - .buttonStyle(.plain) } + .scrollIndicators(.visible) + .frame(maxWidth: .infinity) } + } else { + Spacer(minLength: 24) } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + private var connectionControls: some View { + VStack(alignment: .leading, spacing: 10) { HStack { TextField(L10n.string("add_device.host_or_ip"), text: Binding( get: { store.hostFieldText }, @@ -343,6 +418,12 @@ private struct AddDeviceView: View { )) .disabled(!store.isHostFieldEditable) SecureField(L10n.string("add_device.password"), text: $store.password) + .onSubmit { + guard store.canConfigure else { + return + } + store.runConfigure() + } } HStack { @@ -370,8 +451,6 @@ private struct AddDeviceView: View { ErrorBlock(error: error) } } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private var statusIcon: String { @@ -416,9 +495,12 @@ private struct DeviceCandidateRow: View { .foregroundStyle(.secondary) } Spacer() - Text(device.model ?? device.syap ?? "") - .font(.caption) - .foregroundStyle(.secondary) + if !device.discoveryModelText.isEmpty { + Text(device.discoveryModelText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } } .padding(.vertical, 6) } @@ -508,8 +590,10 @@ private struct OverviewTab: View { HStack { SecureField(L10n.string("dashboard.replacement_password"), text: $replacementPassword) Button { - try? appStore.savePassword(replacementPassword, for: profile) - replacementPassword = "" + Task { @MainActor in + try? await appStore.savePassword(replacementPassword, for: profile) + replacementPassword = "" + } } label: { Label(L10n.string("dashboard.action.save_password"), systemImage: "key") } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift index 333b9f1a..9c89a767 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift @@ -152,8 +152,9 @@ final class DashboardStore: ObservableObject { doctorStore.$passwordInvalidProfileID .sink { [weak self] profileID in guard let profileID else { return } - Task { @MainActor in - self?.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) + Task { @MainActor [weak self] in + guard let self else { return } + await self.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) } } .store(in: &cancellables) @@ -167,16 +168,18 @@ final class DashboardStore: ObservableObject { deployStore.$passwordInvalidProfileID .sink { [weak self] profileID in guard let profileID else { return } - Task { @MainActor in - self?.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) + Task { @MainActor [weak self] in + guard let self else { return } + await 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) + Task { @MainActor [weak self] in + guard let self else { return } + await self.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) } } .store(in: &cancellables) @@ -253,14 +256,16 @@ final class DashboardStore: ObservableObject { let summary = doctorStore.summary else { return } - appStore.deviceRegistry.updateCheckup(DeviceCheckupSnapshot( - checkedAt: Date(), - state: state, - passCount: summary.passCount, - warnCount: summary.warnCount, - failCount: summary.failCount, - summary: L10n.format("summary.checkup_counts", summary.passCount, summary.warnCount, summary.failCount) - ), for: profileID) + Task { + await appStore.deviceRegistry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(), + state: state, + passCount: summary.passCount, + warnCount: summary.warnCount, + failCount: summary.failCount, + summary: L10n.format("summary.checkup_counts", summary.passCount, summary.warnCount, summary.failCount) + ), for: profileID) + } } private func updateDeploySnapshot(state: DeployWorkflowState) { @@ -276,13 +281,15 @@ final class DashboardStore: ObservableObject { let result = deployStore.result else { return } - appStore.deviceRegistry.updateDeploy(DeviceDeploySnapshot( - deployedAt: Date(), - state: state, - payloadFamily: deployStore.plan?.payloadFamily ?? profile.payloadFamily, - rebootRequested: result.rebootRequested, - verified: result.verified, - summary: result.message ?? L10n.string("deploy.result.default_message") - ), for: profile.id) + Task { + await appStore.deviceRegistry.updateDeploy(DeviceDeploySnapshot( + deployedAt: Date(), + state: state, + payloadFamily: deployStore.plan?.payloadFamily ?? profile.payloadFamily, + rebootRequested: result.rebootRequested, + verified: result.verified, + summary: result.message ?? L10n.string("deploy.result.default_message") + ), for: profile.id) + } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift index 7dc28c67..d1a9fef3 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift @@ -42,10 +42,7 @@ final class DeviceRegistryStore: ObservableObject { let registryURL: URL let devicesDirectoryURL: URL - private let fileManager: FileManager - private let encoder: JSONEncoder - private let decoder: JSONDecoder - private let now: () -> Date + private let repository: DeviceRegistryRepository convenience init() { let appSupport = BundleLayout.applicationSupportDirectory() ?? FileManager.default.homeDirectoryForCurrentUser @@ -57,6 +54,206 @@ final class DeviceRegistryStore: ObservableObject { applicationSupportURL: URL, fileManager: FileManager = .default, now: @escaping () -> Date = Date.init + ) { + self.applicationSupportURL = applicationSupportURL + self.registryURL = applicationSupportURL.appendingPathComponent("devices.json") + self.devicesDirectoryURL = applicationSupportURL.appendingPathComponent("Devices", isDirectory: true) + self.repository = DeviceRegistryRepository( + applicationSupportURL: applicationSupportURL, + fileManager: fileManager, + now: now + ) + } + + var isEmpty: Bool { + profiles.isEmpty + } + + func load() async { + state = .loading + error = nil + do { + profiles = try await repository.load() + state = profiles.isEmpty ? .empty : .loaded + } catch { + fail(error, clearProfiles: true) + } + } + + @discardableResult + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() + ) async throws -> DeviceProfile { + state = .saving + error = nil + do { + let result = try await repository.saveConfiguredDevice( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: passwordState, + preferredID: preferredID + ) + await refreshProfilesFromRepository() + return result.profile + } catch { + fail(error, clearProfiles: false) + throw error + } + } + + func makeConfiguredDeviceProfile( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() + ) async -> DeviceProfile { + await repository.makeConfiguredDeviceProfile( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: passwordState, + preferredID: preferredID + ) + } + + @discardableResult + func saveProfileMergingDuplicates(_ profile: DeviceProfile) async throws -> DeviceProfile { + state = .saving + error = nil + do { + let result = try await repository.saveProfileMergingDuplicates(profile) + await refreshProfilesFromRepository() + return result.profile + } catch { + fail(error, clearProfiles: false) + throw error + } + } + + func discardArtifacts(for profile: DeviceProfile) async { + await repository.discardArtifacts(for: profile) + } + + @discardableResult + func updateProfile(_ profile: DeviceProfile) async throws -> DeviceProfile { + state = .saving + error = nil + do { + let result = try await repository.updateProfile(profile) + await refreshProfilesFromRepository() + return result.profile + } catch { + fail(error, clearProfiles: false) + throw error + } + } + + func delete(_ profile: DeviceProfile) async throws { + state = .saving + error = nil + do { + _ = try await repository.delete(profile) + await refreshProfilesFromRepository() + } catch { + fail(error, clearProfiles: false) + throw error + } + } + + func updatePasswordState(_ state: DevicePasswordState, for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.updatePasswordState(state, for: profileID) + } + } + + func updateCheckup(_ snapshot: DeviceCheckupSnapshot, for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.updateCheckup(snapshot, for: profileID) + } + } + + func updateDeploy(_ snapshot: DeviceDeploySnapshot, for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.updateDeploy(snapshot, for: profileID) + } + } + + func profile(id: DeviceProfile.ID?) -> DeviceProfile? { + guard let id else { + return nil + } + return profiles.first { $0.id == id } + } + + func matchingProfile(host: String, bonjourFullname: String?) -> DeviceProfile? { + let 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 applyBackgroundMutation(_ mutate: () async throws -> [DeviceProfile]?) async { + do { + guard try await mutate() != nil else { + return + } + await refreshProfilesFromRepository() + } catch { + fail(error, clearProfiles: false) + } + } + + private func refreshProfilesFromRepository() async { + profiles = await repository.profilesSnapshot() + state = profiles.isEmpty ? .empty : .loaded + } + + private func fail(_ error: Error, clearProfiles: Bool) { + if clearProfiles { + profiles = [] + } + if let registryError = error as? DeviceRegistryError { + self.error = registryError + switch registryError { + case .profileNotFound, .duplicateProfile: + state = profiles.isEmpty ? .empty : .loaded + return + case .applicationSupportUnavailable, .corruptRegistry, .io: + break + } + } else { + self.error = .io(error.localizedDescription) + } + state = .failed + } +} + +private struct DeviceRegistryMutationResult: Sendable { + let profile: DeviceProfile +} + +private actor DeviceRegistryRepository { + private let applicationSupportURL: URL + private let registryURL: URL + private let devicesDirectoryURL: URL + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let now: () -> Date + private var profiles: [DeviceProfile] = [] + + init( + applicationSupportURL: URL, + fileManager: FileManager, + now: @escaping () -> Date ) { self.applicationSupportURL = applicationSupportURL self.registryURL = applicationSupportURL.appendingPathComponent("devices.json") @@ -74,56 +271,38 @@ final class DeviceRegistryStore: ObservableObject { self.decoder = decoder } - var isEmpty: Bool { - profiles.isEmpty - } - - func load() { - state = .loading - error = nil + func load() throws -> [DeviceProfile] { do { try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) guard fileManager.fileExists(atPath: registryURL.path) else { profiles = [] - state = .empty - return + return profiles } let data = try Data(contentsOf: registryURL) profiles = try decoder.decode([DeviceProfile].self, from: data) .sorted { $0.updatedAt > $1.updatedAt } - state = profiles.isEmpty ? .empty : .loaded + return profiles } catch let decoding as DecodingError { profiles = [] - error = .corruptRegistry(String(describing: decoding)) - state = .failed + throw DeviceRegistryError.corruptRegistry(String(describing: decoding)) + } catch let registryError as DeviceRegistryError { + profiles = [] + throw registryError } catch { profiles = [] - self.error = .io(error.localizedDescription) - state = .failed + throw DeviceRegistryError.io(error.localizedDescription) } } - @discardableResult - func saveConfiguredDevice( - configuredDevice: ConfiguredDeviceState, - discoveredDevice: DiscoveredDevice?, - passwordState: DevicePasswordState, - preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() - ) throws -> DeviceProfile { - let profile = makeConfiguredDeviceProfile( - configuredDevice: configuredDevice, - discoveredDevice: discoveredDevice, - passwordState: passwordState, - preferredID: preferredID - ) - return try saveProfileMergingDuplicates(profile) + func profilesSnapshot() -> [DeviceProfile] { + profiles } func makeConfiguredDeviceProfile( configuredDevice: ConfiguredDeviceState, discoveredDevice: DiscoveredDevice?, passwordState: DevicePasswordState, - preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() + preferredID: DeviceProfile.ID ) -> DeviceProfile { let existing = matchingProfile(host: configuredDevice.host, bonjourFullname: discoveredDevice?.fullname) var profile = DeviceProfile.make( @@ -138,28 +317,33 @@ final class DeviceRegistryStore: ObservableObject { return profile } - @discardableResult - func saveProfileMergingDuplicates(_ profile: DeviceProfile) throws -> DeviceProfile { - state = .saving - error = nil - do { - try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) - try fileManager.createDirectory( - at: URL(fileURLWithPath: profile.configPath).deletingLastPathComponent(), - withIntermediateDirectories: true - ) - var updated = profiles.filter { !DeviceProfile.matches($0, profile) && $0.id != profile.id } - updated.append(profile) - updated = updated.sorted { $0.updatedAt > $1.updatedAt } - try persist(updated) - profiles = updated - state = profiles.isEmpty ? .empty : .loaded - return profile - } catch { - self.error = .io(error.localizedDescription) - state = .failed - throw error - } + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID + ) throws -> DeviceRegistryMutationResult { + let profile = makeConfiguredDeviceProfile( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: passwordState, + preferredID: preferredID + ) + return try saveProfileMergingDuplicates(profile) + } + + func saveProfileMergingDuplicates(_ profile: DeviceProfile) throws -> DeviceRegistryMutationResult { + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: URL(fileURLWithPath: profile.configPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var updated = profiles.filter { !DeviceProfile.matches($0, profile) && $0.id != profile.id } + updated.append(profile) + updated = sorted(updated) + try persist(updated) + profiles = updated + return DeviceRegistryMutationResult(profile: profile) } func discardArtifacts(for profile: DeviceProfile) { @@ -172,110 +356,83 @@ final class DeviceRegistryStore: ObservableObject { try? fileManager.removeItem(at: configDirectory) } - @discardableResult - func updateProfile(_ profile: DeviceProfile) throws -> DeviceProfile { + func updateProfile(_ profile: DeviceProfile) throws -> DeviceRegistryMutationResult { guard let index = profiles.firstIndex(where: { $0.id == profile.id }) else { - let error = DeviceRegistryError.profileNotFound(profile.id) - self.error = error - throw error + throw DeviceRegistryError.profileNotFound(profile.id) } if let conflict = duplicateConflict(for: profile, excluding: profile.id) { - self.error = conflict throw conflict } - state = .saving - error = nil + var updated = profile updated.updatedAt = now() - do { - try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) - try fileManager.createDirectory( - at: URL(fileURLWithPath: updated.configPath).deletingLastPathComponent(), - withIntermediateDirectories: true - ) - var updatedProfiles = profiles - updatedProfiles[index] = updated - updatedProfiles = updatedProfiles.sorted { $0.updatedAt > $1.updatedAt } - try persist(updatedProfiles) - profiles = updatedProfiles - state = profiles.isEmpty ? .empty : .loaded - return updated - } catch { - self.error = .io(error.localizedDescription) - state = .failed - throw error - } + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: URL(fileURLWithPath: updated.configPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var updatedProfiles = profiles + updatedProfiles[index] = updated + updatedProfiles = sorted(updatedProfiles) + try persist(updatedProfiles) + profiles = updatedProfiles + return DeviceRegistryMutationResult(profile: updated) } - func delete(_ profile: DeviceProfile) throws { - state = .saving - error = nil - do { - let updatedProfiles = profiles.filter { $0.id != profile.id } - let configDirectory = URL(fileURLWithPath: profile.configPath).deletingLastPathComponent() - try persist(updatedProfiles) - profiles = updatedProfiles - if fileManager.fileExists(atPath: configDirectory.path) { - try fileManager.removeItem(at: configDirectory) - } - state = profiles.isEmpty ? .empty : .loaded - } catch { - self.error = .io(error.localizedDescription) - state = .failed - throw error + func delete(_ profile: DeviceProfile) throws -> [DeviceProfile] { + 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) } + return updatedProfiles } - func updatePasswordState(_ state: DevicePasswordState, for profileID: DeviceProfile.ID) { + func updatePasswordState(_ state: DevicePasswordState, for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { - return + return nil } guard profiles[index].passwordState != state else { - return + return nil } var updatedProfiles = profiles updatedProfiles[index].passwordState = state updatedProfiles[index].updatedAt = now() - if (try? persist(updatedProfiles)) != nil { - profiles = updatedProfiles - } + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles } - func updateCheckup(_ snapshot: DeviceCheckupSnapshot, for profileID: DeviceProfile.ID) { + func updateCheckup(_ snapshot: DeviceCheckupSnapshot, for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { - return + return nil } var updatedProfiles = profiles updatedProfiles[index].lastCheckup = snapshot updatedProfiles[index].updatedAt = now() - if (try? persist(updatedProfiles)) != nil { - profiles = updatedProfiles - } + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles } - func updateDeploy(_ snapshot: DeviceDeploySnapshot, for profileID: DeviceProfile.ID) { + func updateDeploy(_ snapshot: DeviceDeploySnapshot, for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { - return + return nil } var updatedProfiles = profiles updatedProfiles[index].lastDeploy = snapshot updatedProfiles[index].updatedAt = now() - if (try? persist(updatedProfiles)) != nil { - profiles = updatedProfiles - } - } - - func profile(id: DeviceProfile.ID?) -> DeviceProfile? { - guard let id else { - return nil - } - return profiles.first { $0.id == id } + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles } - 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 }) { + private func matchingProfile(host: String, bonjourFullname: String?) -> DeviceProfile? { + let normalizedFullname = normalizedBonjourFullname(bonjourFullname) + if let normalizedFullname, + let profile = profiles.first(where: { normalizedBonjourFullname($0.bonjourFullname) == normalizedFullname }) { return profile } let normalizedHost = DeviceProfile.normalizedHost(host) @@ -322,4 +479,8 @@ final class DeviceRegistryStore: ObservableObject { let data = try encoder.encode(profiles) try data.write(to: registryURL, options: [.atomic]) } + + private func sorted(_ profiles: [DeviceProfile]) -> [DeviceProfile] { + profiles.sorted { $0.updatedAt > $1.updatedAt } + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperPipeReader.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperPipeReader.swift new file mode 100644 index 00000000..2624de6c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperPipeReader.swift @@ -0,0 +1,70 @@ +import Foundation + +public protocol HelperPipeReading: Sendable { + func chunks(from handle: FileHandle) -> AsyncThrowingStream +} + +public final class ReadabilityPipeReader: HelperPipeReading, @unchecked Sendable { + public init() {} + + public func chunks(from handle: FileHandle) -> AsyncThrowingStream { + let state = PipeReadState(handle: handle) + return AsyncThrowingStream { continuation in + state.start(continuation: continuation) + } + } +} + +private final class PipeReadState: @unchecked Sendable { + private let handle: FileHandle + private let lock = NSLock() + private var completed = false + + init(handle: FileHandle) { + self.handle = handle + } + + func start(continuation: AsyncThrowingStream.Continuation) { + continuation.onTermination = { _ in + self.finish() + } + + handle.readabilityHandler = { readableHandle in + self.readAvailableData(from: readableHandle, continuation: continuation) + } + } + + private func readAvailableData( + from readableHandle: FileHandle, + continuation: AsyncThrowingStream.Continuation + ) { + guard !isCompleted else { + return + } + let data = readableHandle.availableData + guard !data.isEmpty else { + finish() + continuation.finish() + return + } + continuation.yield(data) + } + + private var isCompleted: Bool { + lock.lock() + let value = completed + lock.unlock() + return value + } + + private func finish() { + lock.lock() + guard !completed else { + lock.unlock() + return + } + completed = true + lock.unlock() + handle.readabilityHandler = nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift index 4d800db2..a32bb193 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(Darwin) +import Darwin +#endif public protocol HelperRequestWriting: Sendable { func write(_ data: Data, to handle: FileHandle) async throws @@ -16,6 +19,10 @@ public final class PipeRequestWriter: HelperRequestWriting, @unchecked Sendable guard !data.isEmpty else { return } + #if canImport(Darwin) + var noSigpipe: CInt = 1 + _ = fcntl(handle.fileDescriptor, F_SETNOSIGPIPE, &noSigpipe) + #endif let state = PipeRequestWriteState(data: data, handle: handle, chunkSize: chunkSize) try await withTaskCancellationHandler { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index 14d56bc7..36db2544 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -18,20 +18,21 @@ public protocol HelperRunning: Sendable { } public final class HelperRunner: @unchecked Sendable, HelperRunning { - private static let pipeReadChunkSize = 4096 - private let locator: HelperLocator private let stderrLimit: Int private let requestWriter: any HelperRequestWriting + private let pipeReader: any HelperPipeReading public init( locator: HelperLocator = HelperLocator(), stderrLimit: Int = 64 * 1024, - requestWriter: any HelperRequestWriting = PipeRequestWriter() + requestWriter: any HelperRequestWriting = PipeRequestWriter(), + pipeReader: any HelperPipeReading = ReadabilityPipeReader() ) { self.locator = locator self.stderrLimit = stderrLimit self.requestWriter = requestWriter + self.pipeReader = pipeReader } public func run( @@ -74,12 +75,13 @@ public final class HelperRunner: @unchecked Sendable, HelperRunning { return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") } + let pipeReader = self.pipeReader let stdoutTask = Task.detached { - await Self.readOutput(output.fileHandleForReading, onEvent: eventSink) + await Self.readOutput(output.fileHandleForReading, pipeReader: pipeReader, onEvent: eventSink) } let stderrLimit = self.stderrLimit let stderrTask = Task.detached { - Self.readCapped(error.fileHandleForReading, limit: stderrLimit) + await Self.readCapped(error.fileHandleForReading, limit: stderrLimit, pipeReader: pipeReader) } let requestData: Data @@ -171,40 +173,40 @@ public final class HelperRunner: @unchecked Sendable, HelperRunning { private static func readOutput( _ handle: FileHandle, + pipeReader: any HelperPipeReading, 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) + do { + for try await data in pipeReader.chunks(from: handle) { + for event in parser.append(data) { + await onEvent(event) + } } + } catch { + return } for event in parser.finish() { await onEvent(event) } } - private static func readCapped(_ handle: FileHandle, limit: Int) -> String { + private static func readCapped( + _ handle: FileHandle, + limit: Int, + pipeReader: any HelperPipeReading + ) async -> String { var output = Data() - while let data = readChunk(from: handle) { - if output.count < limit { - output.append(data.prefix(limit - output.count)) - } - } - return String(decoding: output, as: UTF8.self) - } - - private static func readChunk(from handle: FileHandle) -> Data? { - let data: Data? do { - data = try handle.read(upToCount: pipeReadChunkSize) + for try await data in pipeReader.chunks(from: handle) { + if output.count < limit { + output.append(data.prefix(limit - output.count)) + } + } } catch { - return nil - } - guard let data, !data.isEmpty else { - return nil + return String(decoding: output, as: UTF8.self) } - return data + return String(decoding: output, as: UTF8.self) } private static func waitForExit(_ process: Process) async { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 5d1c9b5d..05d7d3d7 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -9,7 +9,7 @@ "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.discover.placeholder" = "Browse for AirPort Bonjour services:"; "add_device.discovered_devices" = "Discovered Devices"; "add_device.entry.discover" = "Discover"; "add_device.entry.manual" = "Manual Address"; @@ -115,7 +115,7 @@ "dashboard.overview.password" = "Password"; "dashboard.overview.payload" = "Payload"; "dashboard.overview.status" = "Status"; -"dashboard.replacement_password" = "Replacement password"; +"dashboard.replacement_password" = "Update saved password"; "dashboard.tab.advanced" = "Advanced"; "dashboard.tab.checkup" = "Checkup"; "dashboard.tab.install" = "Install / Update"; @@ -172,6 +172,7 @@ "helper.error.missing_terminal_event" = "Helper exited without a result or error event."; "host_warning.time_machine.message" = "macOS %d.%d.%d has known Time Machine network backup issues. SMB may work, but backup reliability can be affected by the host OS."; "host_warning.time_machine.title" = "macOS Time Machine Warning"; +"activity.app_ready" = "App Ready"; "activity.last_operation" = "Last operation"; "activity.no_active_operation" = "No active operation"; "maintenance.action.choose" = "Choose"; @@ -309,6 +310,7 @@ "toolbar.add" = "Add"; "toolbar.clear" = "Clear"; "toolbar.diagnostics" = "Diagnostics"; +"toolbar.disabled" = "Disabled"; "toolbar.forget" = "Forget"; "value.auto" = "Auto"; "value.never" = "Never"; diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift index 044848eb..9d1d337e 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift @@ -68,4 +68,36 @@ final class ActivityStoreTests: XCTestCase { XCTAssertEqual(activity.snapshot.scope, .app) XCTAssertEqual(activity.snapshot.operationTitle, "App Readiness") } + + func testSuccessfulAppValidationPresentsAppReadyWithoutDetailMessage() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "validate-install", + stage: "validate_install", + description: "Validate local helper and artifact prerequisites." + ), + BackendEvent( + type: "result", + operation: "validate-install", + ok: true, + payload: .object(["summary": .string("install validation passed.")]) + ) + ], delayNanoseconds: 80_000_000) + ]) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let activity = ActivityStore(coordinator: coordinator) + + backend.run(operation: "validate-install") + + try await waitUntilStoreState { activity.snapshot.isRunning } + XCTAssertEqual(activity.snapshot.operationTitle, "App Readiness") + XCTAssertEqual(activity.snapshot.scope, .app) + + try await waitUntilStoreState { !activity.snapshot.isRunning && activity.snapshot.operationTitle == "App Ready" } + XCTAssertEqual(activity.snapshot.scope, .app) + XCTAssertNil(activity.snapshot.latestMessage) + } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift index 372aba14..6981ad7d 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -25,7 +25,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { } func testDiscoverEmptyReadyAndFailureStates() async throws { - let empty = try makeStore(responses: [ + let empty = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) ]) @@ -34,7 +34,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { try await waitUntilStoreState { empty.store.state == .discoveryEmpty } XCTAssertEqual(empty.store.devices, []) - let ready = try makeStore(responses: [ + let ready = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ testDeviceRecord(name: "A", hostname: "a.local.", ipv4: ["10.0.0.2"], fullname: "A._airport._tcp.local."), @@ -47,7 +47,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(ready.store.devices.count, 2) XCTAssertNil(ready.store.selectedDeviceID) - let failed = try makeStore(responses: [ + let failed = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "error", operation: "discover", code: "bonjour_failed", message: "mDNS failed") ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) @@ -127,7 +127,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { selectedRecord: records[0] ) ] - let fixture = try makeStore(responses: [ + let fixture = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: records, devices: devices)) ]) @@ -141,6 +141,59 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(fixture.store.devices[1].addresses, ["169.254.44.9", "10.0.0.2"]) } + func testDiscoveredDeviceModelTextUsesFullModelIdentifier() throws { + let payload = try testDiscoveredDevice( + syap: "116", + model: "TimeCapsule6,116" + ).decode(DiscoveredDevicePayload.self) + + let device = DiscoveredDevice(payload: payload, index: 0) + + XCTAssertEqual(device.model, "TimeCapsule6,116") + XCTAssertEqual(device.discoveryModelText, "TimeCapsule6,116") + } + + func testDiscoveredDeviceModelTextCanUseSelectedRecordModel() throws { + let selectedRecord = testDeviceRecord( + name: "Office Capsule", + hostname: "office-capsule.local.", + ipv4: ["10.0.0.2"], + syap: "116", + model: "TimeCapsule6,116" + ) + let payload = try testDiscoveredDevice( + syap: "116", + model: nil, + selectedRecord: selectedRecord + ).decode(DiscoveredDevicePayload.self) + + let device = DiscoveredDevice(payload: payload, index: 0) + + XCTAssertEqual(device.model, "TimeCapsule6,116") + XCTAssertEqual(device.discoveryModelText, "TimeCapsule6,116") + } + + func testDiscoveredDeviceModelTextDoesNotFallbackToSyAP() throws { + let selectedRecord = testDeviceRecord( + name: "Office Capsule", + hostname: "office-capsule.local.", + ipv4: ["10.0.0.2"], + syap: "116", + model: "" + ) + let payload = try testDiscoveredDevice( + syap: "116", + model: nil, + selectedRecord: selectedRecord + ).decode(DiscoveredDevicePayload.self) + + let device = DiscoveredDevice(payload: payload, index: 0) + + XCTAssertEqual(device.syap, "116") + XCTAssertNil(device.model) + XCTAssertEqual(device.discoveryModelText, "") + } + func testModeChoiceSeparatesDiscoverAndManualFlows() async throws { let record = testDeviceRecord( name: "Office Capsule", @@ -148,7 +201,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { ipv4: ["10.0.0.2"], fullname: "Office Capsule._airport._tcp.local." ) - let fixture = try makeStore(responses: [ + let fixture = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) ]) @@ -171,8 +224,8 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertNil(fixture.store.selectedDeviceID) } - func testResetClearsPasswordAndSetupInputs() throws { - let fixture = try makeStore(responses: []) + func testResetClearsPasswordAndSetupInputs() async throws { + let fixture = try await makeStore(responses: []) fixture.store.startManualEntry() fixture.store.manualHost = "10.0.0.2" fixture.store.password = "secret" @@ -188,7 +241,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { } func testManualHostConfigureSuccessSavesProfileAndPassword() async throws { - let fixture = try makeStore(responses: [ + let fixture = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "root@10.0.0.2")) ]) @@ -216,7 +269,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { } func testConfigureRejectedWhileAnotherOperationRunsSavesNothing() async throws { - let fixture = try makeStore(responses: [ + let fixture = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) ], delayNanoseconds: 100_000_000) @@ -244,7 +297,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { ipv4: ["10.0.0.5"], fullname: "Office Capsule._airport._tcp.local." ) - let fixture = try makeStore(responses: [ + let fixture = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) ]), @@ -270,7 +323,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { } func testAuthFailureAndUnsupportedDeviceSaveNothing() async throws { - let auth = try makeStore(responses: [ + let auth = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "error", operation: "configure", code: "auth_failed", message: "bad password") ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) @@ -283,7 +336,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(auth.registry.profiles, []) XCTAssertNil(auth.store.savedProfile) - let unsupported = try makeStore(responses: [ + let unsupported = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "error", operation: "configure", code: "unsupported_device", message: "unsupported") ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) @@ -298,7 +351,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { } func testDuplicateHostUpdatesExistingProfileAfterConfigureSucceeds() async throws { - let fixture = try makeStore(responses: [ + let fixture = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload( host: "10.0.0.2", @@ -306,7 +359,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { )) ]) ]) - let existing = try fixture.registry.saveConfiguredDevice( + let existing = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "Original Capsule"), discoveredDevice: nil, passwordState: .available, @@ -326,7 +379,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { } func testKeychainSaveFailureDoesNotSaveProfile() async throws { - let fixture = try makeStore(responses: [ + let fixture = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "10.0.0.2")) ]) @@ -350,12 +403,12 @@ final class AddDeviceFlowStoreTests: XCTestCase { ipv4: ["10.0.0.2"], fullname: "Office Capsule._airport._tcp.local." ) - let fixture = try makeStore(responses: [ + let fixture = try await makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) ]) ]) - let existing = try fixture.registry.saveConfiguredDevice( + let existing = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: try DiscoveredDevice(record: record.decode(BonjourResolvedServicePayload.self), index: 0), passwordState: .available, @@ -371,7 +424,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls.count, 1) } - private func makeStore(responses: [StoreTestRunner.Response]) throws -> ( + private func makeStore(responses: [StoreTestRunner.Response]) async throws -> ( store: AddDeviceFlowStore, runner: StoreTestRunner, registry: DeviceRegistryStore, @@ -379,7 +432,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { ) { let temp = try TemporaryDirectory() let registry = DeviceRegistryStore(applicationSupportURL: temp.url) - registry.load() + await registry.load() let runner = StoreTestRunner(responses: responses) let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) let passwordStore = InMemoryPasswordStore() diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift index 0185a11a..ffd1d1a7 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift @@ -3,26 +3,31 @@ import XCTest @MainActor final class ConfiguredDeviceProfileSaverTests: XCTestCase { - func testKeychainFailureDoesNotPersistProfile() throws { + func testKeychainFailureDoesNotPersistProfile() async throws { let temp = try TemporaryDirectory() let registry = DeviceRegistryStore(applicationSupportURL: temp.url) - registry.load() + await 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" - )) + do { + _ = try await saver.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + password: "secret", + preferredID: "device-one" + ) + XCTFail("Expected keychain save failure.") + } catch { + XCTAssertNotNil(error) + } XCTAssertEqual(registry.profiles, []) XCTAssertEqual(passwordStore.state(for: "device-one"), .missing) } - func testRegistryFailureRollsBackNewKeychainPassword() throws { + func testRegistryFailureRollsBackNewKeychainPassword() async throws { let temp = try TemporaryDirectory() let blockedApplicationSupport = temp.url.appendingPathComponent("not-a-directory") try "file".write(to: blockedApplicationSupport, atomically: true, encoding: .utf8) @@ -30,22 +35,27 @@ final class ConfiguredDeviceProfileSaverTests: XCTestCase { 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" - )) + do { + _ = try await saver.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + password: "secret", + preferredID: "device-one" + ) + XCTFail("Expected registry save failure.") + } catch { + XCTAssertNotNil(error) + } XCTAssertEqual(registry.profiles, []) XCTAssertEqual(passwordStore.state(for: "device-one"), .missing) } - func testRegistryFailureRestoresExistingKeychainPassword() throws { + func testRegistryFailureRestoresExistingKeychainPassword() async throws { let temp = try TemporaryDirectory() let registry = DeviceRegistryStore(applicationSupportURL: temp.url) - registry.load() - let existing = try registry.saveConfiguredDevice( + await registry.load() + let existing = try await registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, @@ -57,12 +67,17 @@ final class ConfiguredDeviceProfileSaverTests: XCTestCase { 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" - )) + do { + _ = try await saver.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "Updated Capsule"), + discoveredDevice: nil, + password: "new-secret", + preferredID: "device-one" + ) + XCTFail("Expected registry save failure.") + } catch { + XCTAssertNotNil(error) + } XCTAssertEqual(try passwordStore.password(for: existing.keychainAccount), "old-secret") XCTAssertEqual(registry.profile(id: existing.id)?.model, existing.model) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index 3a0a9d18..3aab8952 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -3,16 +3,16 @@ import XCTest @MainActor final class DashboardStoreTests: XCTestCase { - func testNoDeviceRegistryLeavesNoSelectedProfile() throws { - let fixture = try makeFixture(responses: []) + func testNoDeviceRegistryLeavesNoSelectedProfile() async throws { + let fixture = try await 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( + func testPrimaryActionDerivesFromPasswordCheckupAndDeployState() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .missing, @@ -24,7 +24,7 @@ final class DashboardStoreTests: XCTestCase { try fixture.passwordStore.save("pw", for: profile.keychainAccount) XCTAssertEqual(fixture.appStore.dashboardSummary(for: profile).primaryAction, .runCheckup) - fixture.registry.updateCheckup(DeviceCheckupSnapshot( + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( checkedAt: Date(timeIntervalSince1970: 100), state: .passed, passCount: 2, @@ -35,7 +35,7 @@ final class DashboardStoreTests: XCTestCase { let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) XCTAssertEqual(fixture.appStore.dashboardSummary(for: checked).primaryAction, .installSMB) - fixture.registry.updateDeploy(DeviceDeploySnapshot( + await fixture.registry.updateDeploy(DeviceDeploySnapshot( deployedAt: Date(timeIntervalSince1970: 110), state: .deployed, payloadFamily: "netbsd6_samba4", @@ -46,7 +46,7 @@ final class DashboardStoreTests: XCTestCase { let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) XCTAssertEqual(fixture.appStore.dashboardSummary(for: installed).primaryAction, .openSMB) - fixture.registry.updateCheckup(DeviceCheckupSnapshot( + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( checkedAt: Date(timeIntervalSince1970: 120), state: .warning, passCount: 1, @@ -59,7 +59,7 @@ final class DashboardStoreTests: XCTestCase { } func testDashboardOperationsUpdateLastCheckupAndDeploySnapshots() async throws { - let fixture = try makeFixture(responses: [ + let fixture = try await makeFixture(responses: [ .init(events: [ BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime"), @@ -73,7 +73,7 @@ final class DashboardStoreTests: XCTestCase { BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) ]) ]) - let profile = try fixture.registry.saveConfiguredDevice( + let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, @@ -107,20 +107,20 @@ final class DashboardStoreTests: XCTestCase { } func testCheckupSnapshotUsesStartedProfileWhenSelectionChanges() async throws { - let fixture = try makeFixture(responses: [ + let fixture = try await makeFixture(responses: [ .init(events: [ BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") ])) ], delayNanoseconds: 100_000_000) ]) - let first = try fixture.registry.saveConfiguredDevice( + let first = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, preferredID: "device-one" ) - let second = try fixture.registry.saveConfiguredDevice( + let second = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.3"), discoveredDevice: nil, passwordState: .available, @@ -133,13 +133,16 @@ final class DashboardStoreTests: XCTestCase { dashboard.runCheckup(profile: first) fixture.appStore.select(second) - try await waitUntilStoreState { dashboard.doctorStore.state == .passed } + try await waitUntilStoreState { + dashboard.doctorStore.state == .passed + && fixture.registry.profile(id: first.id)?.lastCheckup?.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: [ + let fixture = try await makeFixture(responses: [ .init(events: [ BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) ]), @@ -147,13 +150,13 @@ final class DashboardStoreTests: XCTestCase { BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) ], delayNanoseconds: 100_000_000) ]) - let first = try fixture.registry.saveConfiguredDevice( + let first = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, preferredID: "device-one" ) - let second = try fixture.registry.saveConfiguredDevice( + let second = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.3"), discoveredDevice: nil, passwordState: .available, @@ -173,9 +176,9 @@ final class DashboardStoreTests: XCTestCase { XCTAssertNil(fixture.registry.profile(id: second.id)?.lastDeploy) } - func testPasswordLookupFailureMarksProfileMissing() throws { - let fixture = try makeFixture(responses: []) - let profile = try fixture.registry.saveConfiguredDevice( + func testPasswordLookupFailureMarksProfileMissing() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .unknown, @@ -186,16 +189,18 @@ final class DashboardStoreTests: XCTestCase { dashboard.runCheckup(profile: profile) XCTAssertEqual(dashboard.passwordError, "Password is required.") - XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) + try await waitUntilStoreState { + fixture.registry.profile(id: profile.id)?.passwordState == .missing + } } func testAuthFailureMarksSavedPasswordInvalid() async throws { - let fixture = try makeFixture(responses: [ + let fixture = try await makeFixture(responses: [ .init(events: [ BackendEvent(type: "error", operation: "doctor", code: "auth_failed", message: "Password rejected.") ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) ]) - let profile = try fixture.registry.saveConfiguredDevice( + let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, @@ -211,9 +216,9 @@ final class DashboardStoreTests: XCTestCase { 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( + func testRecoveryActionsRouteToMaintenanceAndPasswordWorkflows() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, @@ -253,7 +258,7 @@ final class DashboardStoreTests: XCTestCase { } func testRecoveryRunCheckupAndInstallActionsStartBackendOperations() async throws { - let fixture = try makeFixture(responses: [ + let fixture = try await makeFixture(responses: [ .init(events: [ BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") @@ -263,7 +268,7 @@ final class DashboardStoreTests: XCTestCase { BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) ]) ]) - let profile = try fixture.registry.saveConfiguredDevice( + let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, @@ -296,14 +301,14 @@ final class DashboardStoreTests: XCTestCase { } func testRecoveryRetryUsesFailedOperation() async throws { - let fixture = try makeFixture(responses: [ + let fixture = try await makeFixture(responses: [ .init(events: [ BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") ])) ]) ]) - let profile = try fixture.registry.saveConfiguredDevice( + let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, @@ -324,9 +329,9 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(dashboard.selectedTab, .checkup) } - func testNonActionableRecoveryKindsReturnFalse() throws { - let fixture = try makeFixture(responses: []) - let profile = try fixture.registry.saveConfiguredDevice( + func testNonActionableRecoveryKindsReturnFalse() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, @@ -347,9 +352,9 @@ final class DashboardStoreTests: XCTestCase { )) } - func testForgetProfileDeletesRegistryConfigDirectoryAndPassword() throws { - let fixture = try makeFixture(responses: []) - let profile = try fixture.registry.saveConfiguredDevice( + func testForgetProfileDeletesRegistryConfigDirectoryAndPassword() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, @@ -360,7 +365,7 @@ final class DashboardStoreTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: configDirectory.path)) fixture.appStore.select(profile) - try fixture.appStore.forget(profile) + try await fixture.appStore.forget(profile) XCTAssertEqual(fixture.registry.profiles, []) XCTAssertNil(fixture.appStore.selectedProfile) @@ -369,7 +374,7 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(fixture.passwordStore.state(for: profile.keychainAccount), .missing) } - private func makeFixture(responses: [StoreTestRunner.Response]) throws -> ( + private func makeFixture(responses: [StoreTestRunner.Response]) async throws -> ( appStore: AppStore, registry: DeviceRegistryStore, passwordStore: InMemoryPasswordStore, @@ -377,7 +382,7 @@ final class DashboardStoreTests: XCTestCase { ) { let temp = try TemporaryDirectory() let registry = DeviceRegistryStore(applicationSupportURL: temp.url) - registry.load() + await registry.load() let runner = StoreTestRunner(responses: responses) let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) let passwordStore = InMemoryPasswordStore() diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift index b382eb04..1b01c882 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift @@ -7,24 +7,24 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(DeviceRegistryState.allCases, [.idle, .loading, .empty, .loaded, .saving, .failed]) } - func testMissingRegistryStartsEmpty() throws { + func testMissingRegistryStartsEmpty() async throws { let temp = try TemporaryDirectory() let store = DeviceRegistryStore(applicationSupportURL: temp.url) - store.load() + await store.load() XCTAssertEqual(store.state, .empty) XCTAssertEqual(store.profiles, []) XCTAssertTrue(FileManager.default.fileExists(atPath: store.devicesDirectoryURL.path)) } - func testCorruptRegistryEntersFailedStateWithoutDeletingFile() throws { + func testCorruptRegistryEntersFailedStateWithoutDeletingFile() async throws { let temp = try TemporaryDirectory() let registryURL = temp.url.appendingPathComponent("devices.json") try "{ not json".write(to: registryURL, atomically: true, encoding: .utf8) let store = DeviceRegistryStore(applicationSupportURL: temp.url) - store.load() + await store.load() XCTAssertEqual(store.state, .failed) XCTAssertNotNil(store.error) @@ -32,12 +32,12 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(try String(contentsOf: registryURL), "{ not json") } - func testCreateUpdateAndDeleteProfile() throws { + func testCreateUpdateAndDeleteProfile() async throws { let temp = try TemporaryDirectory() let store = DeviceRegistryStore(applicationSupportURL: temp.url) - store.load() + await store.load() - var profile = try store.saveConfiguredDevice( + var profile = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, @@ -50,28 +50,28 @@ final class DeviceRegistryStoreTests: XCTestCase { profile.displayName = "Renamed Capsule" profile.settings.debugLogging = true - let updated = try store.updateProfile(profile) + let updated = try await store.updateProfile(profile) XCTAssertEqual(updated.displayName, "Renamed Capsule") XCTAssertEqual(store.profiles.first?.settings.debugLogging, true) - try store.delete(updated) + try await store.delete(updated) XCTAssertEqual(store.state, .empty) XCTAssertEqual(store.profiles, []) XCTAssertFalse(FileManager.default.fileExists(atPath: URL(fileURLWithPath: updated.configPath).deletingLastPathComponent().path)) } - func testDuplicateSaveUpdatesByHostAndBonjourFullnameButNotWeakMetadata() throws { + func testDuplicateSaveUpdatesByHostAndBonjourFullnameButNotWeakMetadata() async throws { let temp = try TemporaryDirectory() let store = DeviceRegistryStore(applicationSupportURL: temp.url) - store.load() + await store.load() - let first = try store.saveConfiguredDevice( + let first = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "tcapsule.local.", model: "Time Capsule"), discoveredDevice: try discovered(record: testDeviceRecord(fullname: "Office._airport._tcp.local.")), passwordState: .available, preferredID: "device-one" ) - let hostDuplicate = try store.saveConfiguredDevice( + let hostDuplicate = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: " TCAPSULE.LOCAL. ", model: "Updated Model"), discoveredDevice: nil, passwordState: .missing, @@ -81,7 +81,7 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(store.profiles.count, 1) XCTAssertEqual(store.profiles.first?.model, "Updated Model") - let fullnameDuplicate = try store.saveConfiguredDevice( + let fullnameDuplicate = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.9"), discoveredDevice: try discovered(record: testDeviceRecord( hostname: "other.local.", @@ -94,7 +94,7 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(fullnameDuplicate.id, first.id) XCTAssertEqual(store.profiles.count, 1) - _ = try store.saveConfiguredDevice( + _ = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.10", syap: "119", model: "Updated Model"), discoveredDevice: nil, passwordState: .available, @@ -103,17 +103,48 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(store.profiles.count, 2) } - func testUpdateProfileDoesNotMergeDuplicateHostIntoAnotherProfile() throws { + func testConcurrentDuplicateSavesAreSerializedThroughRepository() async throws { let temp = try TemporaryDirectory() let store = DeviceRegistryStore(applicationSupportURL: temp.url) - store.load() - let first = try store.saveConfiguredDevice( + await store.load() + + async let first = store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "Original Capsule"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + async let second = store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: " 10.0.0.2 ", model: "Updated Capsule"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-two" + ) + + let saved = try await [first, second] + + XCTAssertEqual(Set(saved.map(\.id)).count, 1) + XCTAssertEqual(store.profiles.count, 1) + XCTAssertEqual(store.profiles.first?.id, saved[0].id) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let persisted = try decoder.decode([DeviceProfile].self, from: Data(contentsOf: store.registryURL)) + XCTAssertEqual(persisted.count, 1) + XCTAssertEqual(persisted.first?.id, saved[0].id) + } + + func testUpdateProfileDoesNotMergeDuplicateHostIntoAnotherProfile() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await store.load() + let first = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, preferredID: "device-one" ) - let second = try store.saveConfiguredDevice( + let second = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.3"), discoveredDevice: nil, passwordState: .available, @@ -123,7 +154,10 @@ final class DeviceRegistryStoreTests: XCTestCase { var conflictingUpdate = second conflictingUpdate.host = " root@10.0.0.2. " - XCTAssertThrowsError(try store.updateProfile(conflictingUpdate)) { error in + do { + _ = try await store.updateProfile(conflictingUpdate) + XCTFail("Expected duplicate host update to fail.") + } catch { XCTAssertEqual( error as? DeviceRegistryError, .duplicateProfile(field: "host", value: "10.0.0.2", conflictingProfileID: first.id) @@ -134,17 +168,17 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(store.profile(id: second.id)?.host, "10.0.0.3") } - func testUpdateProfileRejectsDuplicateBonjourFullname() throws { + func testUpdateProfileRejectsDuplicateBonjourFullname() async throws { let temp = try TemporaryDirectory() let store = DeviceRegistryStore(applicationSupportURL: temp.url) - store.load() - let first = try store.saveConfiguredDevice( + await store.load() + let first = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: try discovered(record: testDeviceRecord(fullname: "Office._airport._tcp.local.")), passwordState: .available, preferredID: "device-one" ) - var second = try store.saveConfiguredDevice( + var second = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.3"), discoveredDevice: try discovered(record: testDeviceRecord( hostname: "den.local.", @@ -157,7 +191,10 @@ final class DeviceRegistryStoreTests: XCTestCase { second.bonjourFullname = " office._AIRPORT._tcp.local. " - XCTAssertThrowsError(try store.updateProfile(second)) { error in + do { + _ = try await store.updateProfile(second) + XCTFail("Expected duplicate Bonjour fullname update to fail.") + } catch { XCTAssertEqual( error as? DeviceRegistryError, .duplicateProfile( @@ -171,10 +208,10 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(store.profile(id: second.id)?.bonjourFullname, "Den._airport._tcp.local.") } - func testUpdateProfileMissingIDFailsWithoutCreatingProfile() throws { + func testUpdateProfileMissingIDFailsWithoutCreatingProfile() async throws { let temp = try TemporaryDirectory() let store = DeviceRegistryStore(applicationSupportURL: temp.url) - store.load() + await store.load() var profile = DeviceProfile.make( id: "missing", configuredDevice: try testConfiguredDevice(host: "10.0.0.2"), @@ -184,26 +221,29 @@ final class DeviceRegistryStoreTests: XCTestCase { ) profile.displayName = "Unsaved" - XCTAssertThrowsError(try store.updateProfile(profile)) { error in + do { + _ = try await store.updateProfile(profile) + XCTFail("Expected missing profile update to fail.") + } catch { XCTAssertEqual(error as? DeviceRegistryError, .profileNotFound("missing")) } XCTAssertEqual(store.state, .empty) XCTAssertEqual(store.profiles, []) } - func testUpdateProfilePreservesOtherProfilesForLocalEdits() throws { + func testUpdateProfilePreservesOtherProfilesForLocalEdits() async throws { let temp = try TemporaryDirectory() let store = DeviceRegistryStore(applicationSupportURL: temp.url, now: { Date(timeIntervalSince1970: 100) }) - store.load() - var first = try store.saveConfiguredDevice( + await store.load() + var first = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), discoveredDevice: nil, passwordState: .available, preferredID: "device-one" ) - let second = try store.saveConfiguredDevice( + let second = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.3"), discoveredDevice: nil, passwordState: .available, @@ -212,7 +252,7 @@ final class DeviceRegistryStoreTests: XCTestCase { first.displayName = "Office" first.settings.mountWaitSeconds = 45 - let updated = try store.updateProfile(first) + let updated = try await store.updateProfile(first) XCTAssertEqual(updated.displayName, "Office") XCTAssertEqual(updated.settings.mountWaitSeconds, 45) diff --git a/src/timecapsulesmb/discovery/devices.py b/src/timecapsulesmb/discovery/devices.py index 967f802f..78e2ad21 100644 --- a/src/timecapsulesmb/discovery/devices.py +++ b/src/timecapsulesmb/discovery/devices.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import Iterable +from timecapsulesmb.core.config import AIRPORT_SYAP_TO_MODEL from timecapsulesmb.core.net import is_link_local_ipv4 from timecapsulesmb.discovery.bonjour import ( AIRPORT_SERVICE, @@ -13,6 +14,8 @@ record_has_service, ) +_GENERIC_MDNS_MODELS = frozenset({"AirPort", "TimeCapsule", "Time Capsule"}) + @dataclass(frozen=True) class DiscoveredDeviceCandidate: @@ -81,6 +84,8 @@ def _candidate_from_record(record: BonjourResolvedService, index: int) -> Discov host = _host_from_ssh_host(ssh_host) or record.hostname or _first_value(record.ipv6) or "" name = record.name or record.hostname or host or "AirPort Device" fullname = record.fullname or "" + syap = _non_empty(record.properties.get("syAP") or record.properties.get("syap")) + model = _candidate_model(_non_empty(record.properties.get("model") or record.properties.get("am")), syap) return DiscoveredDeviceCandidate( id=_candidate_id(record, host=host, index=index), name=name, @@ -92,14 +97,21 @@ def _candidate_from_record(record: BonjourResolvedService, index: int) -> Discov 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")), + syap=syap, + model=model, service_type=record.service_type or "", fullname=fullname, selected_record=record, ) +def _candidate_model(model: str | None, syap: str | None) -> str | None: + inferred = AIRPORT_SYAP_TO_MODEL.get(syap or "") + if inferred is not None and (model is None or model in _GENERIC_MDNS_MODELS): + return inferred + return model + + def _candidate_score(candidate: DiscoveredDeviceCandidate) -> tuple[int, int, int, int]: return ( 1 if candidate.preferred_ipv4 else 0, diff --git a/tests/test_discovery_devices.py b/tests/test_discovery_devices.py index 1724a903..b5570120 100644 --- a/tests/test_discovery_devices.py +++ b/tests/test_discovery_devices.py @@ -83,6 +83,29 @@ def test_json_payload_keeps_raw_selected_record_for_configure(self) -> None: self.assertEqual(payload["selected_record"]["fullname"], "Office._airport._tcp.local.") self.assertEqual(payload["selected_record"]["ipv4"], ["10.0.0.2"]) + def test_derives_full_model_identifier_from_syap_when_model_is_missing(self) -> None: + record = self.record("Office", "_airport._tcp.local.", ["10.0.0.2"], syap="116", model="") + + device = device_candidates_from_records([record])[0] + + self.assertEqual(device.syap, "116") + self.assertEqual(device.model, "TimeCapsule6,116") + + def test_derives_full_model_identifier_from_syap_when_model_is_generic(self) -> None: + record = self.record("Office", "_airport._tcp.local.", ["10.0.0.2"], syap="119", model="TimeCapsule") + + device = device_candidates_from_records([record])[0] + + self.assertEqual(device.model, "TimeCapsule8,119") + + def test_keeps_explicit_model_when_syap_is_unknown(self) -> None: + record = self.record("Office", "_airport._tcp.local.", ["10.0.0.2"], syap="999", model="MysteryModel") + + device = device_candidates_from_records([record])[0] + + self.assertEqual(device.syap, "999") + self.assertEqual(device.model, "MysteryModel") + def record( self, name: str, From c277c5cdb52d562321f273703e375bd6fcae4ee5 Mon Sep 17 00:00:00 2001 From: James Chang Date: Thu, 21 May 2026 04:07:59 -0700 Subject: [PATCH 022/129] Add per-device dashboard sessions and profile editor --- .../Sources/TimeCapsuleSMBApp/AppStore.swift | 9 + .../ConfiguredDeviceProfileSaver.swift | 44 +- .../TimeCapsuleSMBApp/ContentView.swift | 181 +++++--- .../TimeCapsuleSMBApp/DashboardStore.swift | 84 +++- .../DeployWorkflowStore.swift | 10 +- .../DeviceProfileEditorStore.swift | 404 ++++++++++++++++++ .../DeviceRegistryStore.swift | 15 +- .../TimeCapsuleSMBApp/MaintenanceStore.swift | 10 +- .../Resources/en.lproj/Localizable.strings | 17 + .../TimeCapsuleSMBApp/ValueParsers.swift | 11 + .../DashboardStoreTests.swift | 240 +++++++++-- .../DeviceProfileEditorStoreTests.swift | 272 ++++++++++++ src/timecapsulesmb/core/config.py | 3 +- tests/test_app_api.py | 37 ++ tests/test_config.py | 22 + 15 files changed, 1238 insertions(+), 121 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileEditorStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ValueParsers.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift index cd0c922b..c622ae25 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift @@ -155,6 +155,15 @@ final class AppStore: ObservableObject { try await deviceRegistry.updateProfile(updated) } + @discardableResult + func saveProfileEdits(profile: DeviceProfile, draft: DeviceProfileEditorDraft) async throws -> DeviceProfile { + var updated = profile + updated.displayName = draft.displayName + updated.host = draft.trimmedHost + updated.settings = try draft.validatedSettings() + return try await deviceRegistry.updateProfile(updated) + } + func rename(_ profile: DeviceProfile, displayName: String) async throws { var updated = profile updated.displayName = displayName diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift index 768d6768..94c9f56f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift @@ -1,15 +1,42 @@ import Foundation +struct ConfiguredDeviceProfileOverrides: Equatable { + var displayName: String? + var settings: DeviceProfileSettings? + + static let empty = ConfiguredDeviceProfileOverrides() +} + @MainActor protocol ConfiguredDeviceProfileSaving: AnyObject { func saveConfiguredDevice( configuredDevice: ConfiguredDeviceState, discoveredDevice: DiscoveredDevice?, password: String, - preferredID: DeviceProfile.ID + preferredID: DeviceProfile.ID, + existingProfileID: DeviceProfile.ID?, + overrides: ConfiguredDeviceProfileOverrides ) async throws -> DeviceProfile } +extension ConfiguredDeviceProfileSaving { + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + password: String, + preferredID: DeviceProfile.ID + ) async throws -> DeviceProfile { + try await saveConfiguredDevice( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + password: password, + preferredID: preferredID, + existingProfileID: nil, + overrides: .empty + ) + } +} + @MainActor final class ConfiguredDeviceProfileSaver: ConfiguredDeviceProfileSaving { private enum PasswordRollback { @@ -29,14 +56,23 @@ final class ConfiguredDeviceProfileSaver: ConfiguredDeviceProfileSaving { configuredDevice: ConfiguredDeviceState, discoveredDevice: DiscoveredDevice?, password: String, - preferredID: DeviceProfile.ID + preferredID: DeviceProfile.ID, + existingProfileID: DeviceProfile.ID? = nil, + overrides: ConfiguredDeviceProfileOverrides = .empty ) async throws -> DeviceProfile { - let profile = await registry.makeConfiguredDeviceProfile( + var profile = await registry.makeConfiguredDeviceProfile( configuredDevice: configuredDevice, discoveredDevice: discoveredDevice, passwordState: .available, - preferredID: preferredID + preferredID: preferredID, + existingProfileID: existingProfileID ) + if let displayName = overrides.displayName { + profile.displayName = displayName + } + if let settings = overrides.settings { + profile.settings = settings + } let wasSavedProfile = registry.profile(id: profile.id) != nil let rollback = try passwordRollback(for: profile.keychainAccount) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index be62a076..3869d3b3 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -6,7 +6,6 @@ public struct ContentView: View { @StateObject private var addDeviceStore: AddDeviceFlowStore @StateObject private var dashboardStore: DashboardStore @State private var diagnosticsPresented = false - @State private var replacementPassword = "" @State private var profilePendingDeletion: DeviceProfile? @State private var deleteErrorMessage: String? @@ -231,9 +230,8 @@ public struct ContentView: View { } else if let profile = appStore.selectedProfile { DeviceDashboardView( profile: profile, - dashboardStore: dashboardStore, + session: dashboardStore.session(for: profile), appStore: appStore, - replacementPassword: $replacementPassword, showDiagnostics: { diagnosticsPresented = true } @@ -508,14 +506,13 @@ private struct DeviceCandidateRow: View { private struct DeviceDashboardView: View { let profile: DeviceProfile - @ObservedObject var dashboardStore: DashboardStore + @ObservedObject var session: DeviceDashboardSession @ObservedObject var appStore: AppStore - @Binding var replacementPassword: String let showDiagnostics: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { - Picker("", selection: $dashboardStore.selectedTab) { + Picker("", selection: $session.selectedTab) { ForEach(DeviceDashboardTab.allCases) { tab in Text(tab.title).tag(tab) } @@ -527,17 +524,17 @@ private struct DeviceDashboardView: View { ScrollView { Group { - switch dashboardStore.selectedTab { + switch session.selectedTab { case .overview: - OverviewTab(profile: profile, dashboardStore: dashboardStore, appStore: appStore, replacementPassword: $replacementPassword) + OverviewTab(profile: profile, session: session, appStore: appStore) case .install: - InstallTab(profile: profile, dashboardStore: dashboardStore, showDiagnostics: showDiagnostics) + InstallTab(profile: profile, session: session, showDiagnostics: showDiagnostics) case .checkup: - CheckupTab(profile: profile, dashboardStore: dashboardStore, showDiagnostics: showDiagnostics) + CheckupTab(profile: profile, session: session, showDiagnostics: showDiagnostics) case .maintenance: - MaintenanceTab(profile: profile, dashboardStore: dashboardStore, showDiagnostics: showDiagnostics) + MaintenanceTab(profile: profile, session: session, showDiagnostics: showDiagnostics) case .advanced: - AdvancedTab(profile: profile, appStore: appStore) + AdvancedTab(profile: profile, session: session, appStore: appStore) } } .padding() @@ -549,12 +546,11 @@ private struct DeviceDashboardView: View { private struct OverviewTab: View { let profile: DeviceProfile - @ObservedObject var dashboardStore: DashboardStore + @ObservedObject var session: DeviceDashboardSession @ObservedObject var appStore: AppStore - @Binding var replacementPassword: String var body: some View { - let summary = dashboardStore.summary(for: profile) + let summary = session.summary(for: profile) VStack(alignment: .leading, spacing: 16) { if let warning = summary.hostWarning { WarningBanner(warning: warning) @@ -581,26 +577,26 @@ private struct OverviewTab: View { .buttonStyle(.borderedProminent) Button { - dashboardStore.runCheckup(profile: profile) + session.runCheckup(profile: profile) } label: { Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") } } HStack { - SecureField(L10n.string("dashboard.replacement_password"), text: $replacementPassword) + SecureField(L10n.string("dashboard.replacement_password"), text: $session.replacementPassword) Button { Task { @MainActor in - try? await appStore.savePassword(replacementPassword, for: profile) - replacementPassword = "" + try? await appStore.savePassword(session.replacementPassword, for: profile) + session.replacementPassword = "" } } label: { Label(L10n.string("dashboard.action.save_password"), systemImage: "key") } - .disabled(replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(session.replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - if let passwordError = dashboardStore.passwordError { + if let passwordError = session.passwordError { Text(passwordError) .foregroundStyle(.red) } @@ -627,15 +623,15 @@ private struct OverviewTab: View { private func runPrimary(_ action: DashboardPrimaryAction) { switch action { case .replacePassword: - replacementPassword = "" + session.replacementPassword = "" case .runCheckup: - dashboardStore.runCheckup(profile: profile) + session.runCheckup(profile: profile) case .viewCheckup: - dashboardStore.selectedTab = .checkup + session.selectedTab = .checkup case .openSMB: openSMBAddress() case .installSMB: - dashboardStore.runInstallPlan(profile: profile) + session.runInstallPlan(profile: profile) case .addDevice: appStore.showAddDevice() } @@ -654,31 +650,31 @@ private struct OverviewTab: View { private struct InstallTab: View { let profile: DeviceProfile - @ObservedObject var dashboardStore: DashboardStore + @ObservedObject var session: DeviceDashboardSession let showDiagnostics: () -> Void var body: some View { - let store = dashboardStore.deployStore + let store = session.deployStore VStack(alignment: .leading, spacing: 12) { Text(L10n.string("dashboard.tab.install")) .font(.title2.weight(.semibold)) HStack { - Toggle(L10n.string("toggle.enable_nbns"), isOn: $dashboardStore.deployStore.nbnsEnabled) - Toggle(L10n.string("toggle.no_reboot"), isOn: $dashboardStore.deployStore.noReboot) - Toggle(L10n.string("toggle.no_wait"), isOn: $dashboardStore.deployStore.noWait) - Toggle(L10n.string("toggle.force_debug_logging"), isOn: $dashboardStore.deployStore.debugLogging) - TextField(L10n.string("field.mount_wait"), text: $dashboardStore.deployStore.mountWait) + Toggle(L10n.string("toggle.enable_nbns"), isOn: $session.deployStore.nbnsEnabled) + Toggle(L10n.string("toggle.no_reboot"), isOn: $session.deployStore.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $session.deployStore.noWait) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $session.deployStore.debugLogging) + TextField(L10n.string("field.mount_wait"), text: $session.deployStore.mountWait) .frame(width: 150) } HStack { Button { - dashboardStore.runInstallPlan(profile: profile) + session.runInstallPlan(profile: profile) } label: { Label(L10n.string("deploy.action.plan_install"), systemImage: "doc.text.magnifyingglass") } .disabled(store.isRunning || store.mountWaitValue == nil) Button { - dashboardStore.runInstall(profile: profile) + session.runInstall(profile: profile) } label: { Label(L10n.string("dashboard.action.install_smb"), systemImage: "square.and.arrow.up") } @@ -727,25 +723,25 @@ private struct InstallTab: View { showDiagnostics() return } - _ = dashboardStore.handleRecoveryAction(action, error: error, profile: profile) + _ = session.handleRecoveryAction(action, error: error, profile: profile) } } private struct CheckupTab: View { let profile: DeviceProfile - @ObservedObject var dashboardStore: DashboardStore + @ObservedObject var session: DeviceDashboardSession let showDiagnostics: () -> Void var body: some View { - let store = dashboardStore.doctorStore + let store = session.doctorStore VStack(alignment: .leading, spacing: 12) { Text(L10n.string("dashboard.tab.checkup")) .font(.title2.weight(.semibold)) HStack { - TextField(L10n.string("field.bonjour_timeout"), text: $dashboardStore.doctorStore.bonjourTimeout) + TextField(L10n.string("field.bonjour_timeout"), text: $session.doctorStore.bonjourTimeout) .frame(width: 180) Button { - dashboardStore.runCheckup(profile: profile) + session.runCheckup(profile: profile) } label: { Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") } @@ -788,22 +784,22 @@ private struct CheckupTab: View { showDiagnostics() return } - _ = dashboardStore.handleRecoveryAction(action, error: error, profile: profile) + _ = session.handleRecoveryAction(action, error: error, profile: profile) } } private struct MaintenanceTab: View { let profile: DeviceProfile - @ObservedObject var dashboardStore: DashboardStore + @ObservedObject var session: DeviceDashboardSession let showDiagnostics: () -> Void var body: some View { - let store = dashboardStore.maintenanceStore + let store = session.maintenanceStore let presentation = MaintenanceWorkflowPresentation.presentation(for: store.selectedWorkflow) VStack(alignment: .leading, spacing: 12) { Text(L10n.string("dashboard.tab.maintenance")) .font(.title2.weight(.semibold)) - Picker(L10n.string("dashboard.tab.maintenance"), selection: $dashboardStore.maintenanceStore.selectedWorkflow) { + Picker(L10n.string("dashboard.tab.maintenance"), selection: $session.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) @@ -823,10 +819,10 @@ private struct MaintenanceTab: View { } HStack { - TextField(L10n.string("field.mount_wait"), text: $dashboardStore.maintenanceStore.mountWait) + TextField(L10n.string("field.mount_wait"), text: $session.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) + Toggle(L10n.string("toggle.no_reboot"), isOn: $session.maintenanceStore.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $session.maintenanceStore.noWait) } maintenanceControls(store: store) @@ -848,7 +844,7 @@ private struct MaintenanceTab: View { showDiagnostics() return } - _ = dashboardStore.handleRecoveryAction(action, error: error, profile: profile) + _ = session.handleRecoveryAction(action, error: error, profile: profile) } @ViewBuilder @@ -857,12 +853,12 @@ private struct MaintenanceTab: View { case .activate: HStack { Button(L10n.string("maintenance.action.plan_start_smb")) { - if let password = dashboardStore.maintenancePassword(for: profile) { + if let password = session.maintenancePassword(for: profile) { store.planActivation(password: password, profile: profile) } } Button(L10n.string("maintenance.action.start_smb")) { - if let password = dashboardStore.maintenancePassword(for: profile) { + if let password = session.maintenancePassword(for: profile) { store.runActivation(password: password, profile: profile) } } @@ -872,12 +868,12 @@ private struct MaintenanceTab: View { case .uninstall: HStack { Button(L10n.string("maintenance.action.plan_uninstall")) { - if let password = dashboardStore.maintenancePassword(for: profile) { + if let password = session.maintenancePassword(for: profile) { store.planUninstall(password: password, profile: profile) } } Button(L10n.string("maintenance.action.uninstall")) { - if let password = dashboardStore.maintenancePassword(for: profile) { + if let password = session.maintenancePassword(for: profile) { store.runUninstall(password: password, profile: profile) } } @@ -888,18 +884,18 @@ private struct MaintenanceTab: View { VStack(alignment: .leading, spacing: 8) { HStack { Button(L10n.string("maintenance.action.find_volumes")) { - if let password = dashboardStore.maintenancePassword(for: profile) { + if let password = session.maintenancePassword(for: profile) { store.refreshFsckTargets(password: password, profile: profile) } } Button(L10n.string("maintenance.action.plan_disk_repair")) { - if let password = dashboardStore.maintenancePassword(for: profile) { + if let password = session.maintenancePassword(for: profile) { store.planFsck(password: password, profile: profile) } } .disabled(!store.canPlanFsck) Button(L10n.string("maintenance.action.run_disk_repair")) { - if let password = dashboardStore.maintenancePassword(for: profile) { + if let password = session.maintenancePassword(for: profile) { store.runFsck(password: password, profile: profile) } } @@ -922,7 +918,7 @@ private struct MaintenanceTab: View { case .repairXattrs: VStack(alignment: .leading, spacing: 8) { HStack { - TextField(L10n.string("field.repair_xattrs_path"), text: $dashboardStore.maintenanceStore.repairPath) + TextField(L10n.string("field.repair_xattrs_path"), text: $session.maintenanceStore.repairPath) Button { chooseRepairPath(store: store) } label: { @@ -961,12 +957,14 @@ private struct MaintenanceTab: View { private struct AdvancedTab: View { let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession @ObservedObject var appStore: AppStore var body: some View { VStack(alignment: .leading, spacing: 12) { Text(L10n.string("dashboard.tab.advanced")) .font(.title2.weight(.semibold)) + DeviceProfileEditorView(profile: profile, store: session.profileEditorStore) SummaryGrid(rows: [ (L10n.string("advanced.profile_id"), profile.id), (L10n.string("advanced.config"), profile.configPath), @@ -977,6 +975,79 @@ private struct AdvancedTab: View { } } +private struct DeviceProfileEditorView: View { + let profile: DeviceProfile + @ObservedObject var store: DeviceProfileEditorStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.string("profile_editor.title")) + .font(.headline) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("profile_editor.display_name")) + .foregroundStyle(.secondary) + TextField(L10n.string("profile_editor.display_name"), text: $store.draft.displayName) + .frame(maxWidth: 360) + } + GridRow { + Text(L10n.string("dashboard.overview.host")) + .foregroundStyle(.secondary) + TextField(L10n.string("dashboard.overview.host"), text: $store.draft.host) + .frame(maxWidth: 360) + } + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.draft.mountWaitSeconds) + .frame(width: 160) + } + } + + HStack { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) + } + + HStack { + Button { + Task { @MainActor in + await store.save(profile: profile) + } + } label: { + Label(L10n.string("profile_editor.save"), systemImage: "square.and.arrow.down") + } + .disabled(!store.canSave(profile: profile)) + + Button { + store.reset(to: profile) + } label: { + Label(L10n.string("profile_editor.reset"), systemImage: "arrow.counterclockwise") + } + .disabled(store.isRunning) + + Label(store.state.title, systemImage: "circle") + .foregroundStyle(.secondary) + } + + ForEach(store.validationErrors, id: \.self) { validationError in + Text(validationError.localizedDescription) + .font(.caption) + .foregroundStyle(.red) + } + + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let error = store.error { + ErrorRecoveryView(error: error) { _ in } + } + } + .padding(.bottom, 8) + } +} + private struct AppReadinessBannerView: View { @ObservedObject var store: AppReadinessStore let showDiagnostics: () -> Void diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift index 9c89a767..d4ea520d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift @@ -30,26 +30,33 @@ enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { } @MainActor -final class DashboardStore: ObservableObject { +final class DeviceDashboardSession: ObservableObject, Identifiable { + let id: DeviceProfile.ID @Published var selectedTab: DeviceDashboardTab = .overview + @Published var replacementPassword = "" @Published private(set) var passwordError: String? let appStore: AppStore var deployStore: DeployWorkflowStore var doctorStore: DoctorStore var maintenanceStore: MaintenanceStore + let profileEditorStore: DeviceProfileEditorStore private var activeCheckupOperation: ActiveOperation? private var activeDeployOperation: ActiveOperation? private var cancellables: Set = [] - init(appStore: AppStore) { + init(profile: DeviceProfile, appStore: AppStore) { + self.id = profile.id self.appStore = appStore self.deployStore = DeployWorkflowStore(coordinator: appStore.operationCoordinator) self.doctorStore = DoctorStore(coordinator: appStore.operationCoordinator) self.maintenanceStore = MaintenanceStore(coordinator: appStore.operationCoordinator) + self.profileEditorStore = DeviceProfileEditorStore(profile: profile, appStore: appStore) + applyProfileSettings(profile.settings) forwardChildChanges() observeSnapshots() + observeProfileEditor() } func summary(for profile: DeviceProfile) -> DeviceDashboardSummary { @@ -75,9 +82,6 @@ final class DashboardStore: ObservableObject { } 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) } @@ -141,6 +145,13 @@ final class DashboardStore: ObservableObject { } } + func applyProfileSettings(_ settings: DeviceProfileSettings) { + deployStore.nbnsEnabled = settings.nbnsEnabled + deployStore.debugLogging = settings.debugLogging + deployStore.mountWait = String(settings.mountWaitSeconds) + maintenanceStore.mountWait = String(settings.mountWaitSeconds) + } + private func observeSnapshots() { doctorStore.$state .sink { [weak self] state in @@ -185,6 +196,15 @@ final class DashboardStore: ObservableObject { .store(in: &cancellables) } + private func observeProfileEditor() { + profileEditorStore.$savedProfile + .compactMap { $0 } + .sink { [weak self] profile in + self?.applyProfileSettings(profile.settings) + } + .store(in: &cancellables) + } + private func retry(error: BackendErrorViewModel, profile: DeviceProfile) -> Bool { switch error.operation { case "doctor": @@ -242,6 +262,11 @@ final class DashboardStore: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) + profileEditorStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } private func updateCheckupSnapshot(state: DoctorWorkflowState) { @@ -293,3 +318,52 @@ final class DashboardStore: ObservableObject { } } } + +@MainActor +final class DashboardStore: ObservableObject { + let appStore: AppStore + + private var sessions: [DeviceProfile.ID: DeviceDashboardSession] = [:] + private var cancellables: Set = [] + + init(appStore: AppStore) { + self.appStore = appStore + appStore.deviceRegistry.$profiles + .sink { [weak self] profiles in + Task { @MainActor in + self?.pruneSessions(profiles: profiles) + } + } + .store(in: &cancellables) + appStore.operationCoordinator.$activeOperation + .sink { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.pruneSessions(profiles: self.appStore.deviceRegistry.profiles) + } + } + .store(in: &cancellables) + } + + func session(for profile: DeviceProfile) -> DeviceDashboardSession { + if let session = sessions[profile.id] { + return session + } + let session = DeviceDashboardSession(profile: profile, appStore: appStore) + sessions[profile.id] = session + objectWillChange.send() + return session + } + + func hasSession(for profileID: DeviceProfile.ID) -> Bool { + sessions[profileID] != nil + } + + private func pruneSessions(profiles: [DeviceProfile]) { + let existingIDs = Set(profiles.map(\.id)) + let activeProfileID = appStore.operationCoordinator.activeOperation?.profileID + sessions = sessions.filter { id, _ in + existingIDs.contains(id) || id == activeProfileID + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift index 9a9afd33..b56802c2 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift @@ -116,7 +116,7 @@ final class DeployWorkflowStore: ObservableObject { } var mountWaitValue: Int? { - nonNegativeInteger(mountWait) + ValueParsers.nonNegativeInteger(mountWait) } var canDeploy: Bool { @@ -375,14 +375,6 @@ final class DeployWorkflowStore: ObservableObject { activeOperation = nil } - private func nonNegativeInteger(_ text: String) -> Int? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let value = Int(trimmed), value >= 0 else { - return nil - } - return value - } - private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { if let coordinator { return coordinator.run(operation: operation, params: params, profile: profile) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileEditorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileEditorStore.swift new file mode 100644 index 00000000..264d6330 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileEditorStore.swift @@ -0,0 +1,404 @@ +import Combine +import Foundation + +enum DeviceProfileEditorState: String, CaseIterable, Equatable { + case clean + case dirty + case invalid + case saving + case reconfiguring + case saved + case authFailed + case unsupported + case failed + + var title: String { + switch self { + case .clean: + return L10n.string("profile_editor.state.clean") + case .dirty: + return L10n.string("profile_editor.state.dirty") + case .invalid: + return L10n.string("profile_editor.state.invalid") + case .saving: + return L10n.string("profile_editor.state.saving") + case .reconfiguring: + return L10n.string("profile_editor.state.reconfiguring") + case .saved: + return L10n.string("profile_editor.state.saved") + case .authFailed: + return L10n.string("profile_editor.state.auth_failed") + case .unsupported: + return L10n.string("profile_editor.state.unsupported") + case .failed: + return L10n.string("profile_editor.state.failed") + } + } +} + +enum DeviceProfileEditorValidationError: String, CaseIterable, Equatable, LocalizedError { + case hostRequired + case duplicateHost + case mountWaitInvalid + case passwordRequired + + var errorDescription: String? { + switch self { + case .hostRequired: + return L10n.string("profile_editor.error.host_required") + case .duplicateHost: + return L10n.string("profile_editor.error.duplicate_host") + case .mountWaitInvalid: + return L10n.string("profile_editor.error.mount_wait_invalid") + case .passwordRequired: + return L10n.string("profile_editor.error.password_required") + } + } +} + +struct DeviceProfileEditorDraft: Equatable { + var displayName: String + var host: String + var nbnsEnabled: Bool + var debugLogging: Bool + var mountWaitSeconds: String + + init( + displayName: String, + host: String, + nbnsEnabled: Bool, + debugLogging: Bool, + mountWaitSeconds: String + ) { + self.displayName = displayName + self.host = host + self.nbnsEnabled = nbnsEnabled + self.debugLogging = debugLogging + self.mountWaitSeconds = mountWaitSeconds + } + + init(profile: DeviceProfile) { + self.init( + displayName: profile.displayName, + host: profile.host, + nbnsEnabled: profile.settings.nbnsEnabled, + debugLogging: profile.settings.debugLogging, + mountWaitSeconds: String(profile.settings.mountWaitSeconds) + ) + } + + var trimmedHost: String { + host.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func hostChanged(from profile: DeviceProfile) -> Bool { + trimmedHost != profile.host.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func validatedSettings() throws -> DeviceProfileSettings { + guard let mountWait = ValueParsers.nonNegativeInteger(mountWaitSeconds) else { + throw DeviceProfileEditorValidationError.mountWaitInvalid + } + return DeviceProfileSettings( + nbnsEnabled: nbnsEnabled, + debugLogging: debugLogging, + mountWaitSeconds: mountWait + ) + } +} + +@MainActor +final class DeviceProfileEditorStore: ObservableObject { + @Published var draft: DeviceProfileEditorDraft { + didSet { markDirtyAfterDraftChange() } + } + @Published private(set) var state: DeviceProfileEditorState = .clean + @Published private(set) var validationErrors: [DeviceProfileEditorValidationError] = [] + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var savedProfile: DeviceProfile? + + private let appStore: AppStore + private let coordinator: OperationCoordinator + private let profileSaver: ConfiguredDeviceProfileSaving + private var activeOperation: ActiveOperation? + private var pendingProfile: DeviceProfile? + private var pendingDraft: DeviceProfileEditorDraft? + private var pendingPassword: String? + private var lastProcessedEventCount = 0 + private var isApplyingDraft = false + private var cancellables: Set = [] + + init( + profile: DeviceProfile, + appStore: AppStore, + profileSaver: ConfiguredDeviceProfileSaving? = nil + ) { + self.draft = DeviceProfileEditorDraft(profile: profile) + self.appStore = appStore + self.coordinator = appStore.operationCoordinator + self.profileSaver = profileSaver ?? ConfiguredDeviceProfileSaver( + registry: appStore.deviceRegistry, + passwordStore: appStore.passwordStore + ) + observeBackend() + } + + var isRunning: Bool { + state == .saving || state == .reconfiguring + } + + func canSave(profile: DeviceProfile) -> Bool { + !isRunning && draft != DeviceProfileEditorDraft(profile: profile) + } + + func reset(to profile: DeviceProfile) { + applyDraft(DeviceProfileEditorDraft(profile: profile)) + validationErrors = [] + error = nil + currentStage = nil + savedProfile = nil + state = .clean + clearPendingOperation() + } + + func save(profile: DeviceProfile) async { + let validationErrors = validationErrors(for: profile) + guard validationErrors.isEmpty else { + self.validationErrors = validationErrors + error = nil + state = .invalid + return + } + + if draft.hostChanged(from: profile) { + guard let password = appStore.password(for: profile) else { + self.validationErrors = [.passwordRequired] + error = nil + state = .invalid + return + } + startReconfigure(profile: profile, password: password) + } else { + await saveRegistryOnly(profile: profile) + } + } + + private func observeBackend() { + coordinator.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + private func validationErrors(for profile: DeviceProfile) -> [DeviceProfileEditorValidationError] { + var errors: [DeviceProfileEditorValidationError] = [] + if draft.trimmedHost.isEmpty { + errors.append(.hostRequired) + } else if let duplicate = appStore.deviceRegistry.matchingProfile(host: draft.trimmedHost, bonjourFullname: nil), + duplicate.id != profile.id { + errors.append(.duplicateHost) + } + if ValueParsers.nonNegativeInteger(draft.mountWaitSeconds) == nil { + errors.append(.mountWaitInvalid) + } + return errors + } + + private func saveRegistryOnly(profile: DeviceProfile) async { + state = .saving + validationErrors = [] + error = nil + currentStage = nil + do { + let saved = try await appStore.saveProfileEdits(profile: profile, draft: draft) + savedProfile = saved + applyDraft(DeviceProfileEditorDraft(profile: saved)) + state = .saved + } catch { + failSave(error) + } + } + + private func startReconfigure(profile: DeviceProfile, password: String) { + let params = OperationParams.configure( + host: draft.trimmedHost, + password: password, + debugLogging: draft.debugLogging + ) + let start = coordinator.run( + operation: "configure", + params: params, + context: profile.runtimeContext, + activeDeviceID: profile.id + ) + guard case .started(let operation) = start else { + error = BackendErrorViewModel( + operation: "configure", + code: "operation_rejected", + message: start.rejectionMessage ?? L10n.string("operation.error.already_running") + ) + state = .failed + return + } + lastProcessedEventCount = 0 + activeOperation = operation + pendingProfile = profile + pendingDraft = draft + pendingPassword = password + validationErrors = [] + error = nil + currentStage = nil + savedProfile = nil + state = .reconfiguring + } + + 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 activeOperation?.operation == event.operation, event.operation == "configure" else { + return + } + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + if event.type == "error" { + applyError(event) + return + } + guard event.type == "result" else { + return + } + if event.ok == false { + failFromResult(event) + return + } + applyConfigureResult(event) + } + + private func applyConfigureResult(_ event: BackendEvent) { + let configured: ConfiguredDeviceState + do { + configured = ConfiguredDeviceState(payload: try event.decodePayload(ConfigurePayload.self)) + } catch { + failContract(error) + return + } + guard let profile = pendingProfile, + let draft = pendingDraft, + let password = pendingPassword else { + failContract(DeviceRegistryError.profileNotFound(activeOperation?.profileID ?? "unknown")) + return + } + + state = .saving + Task { @MainActor in + do { + let saved = try await profileSaver.saveConfiguredDevice( + configuredDevice: configured, + discoveredDevice: nil, + password: password, + preferredID: profile.id, + existingProfileID: profile.id, + overrides: ConfiguredDeviceProfileOverrides( + displayName: draft.displayName, + settings: try draft.validatedSettings() + ) + ) + savedProfile = saved + applyDraft(DeviceProfileEditorDraft(profile: saved)) + error = nil + validationErrors = [] + currentStage = nil + state = .saved + clearPendingOperation() + } catch { + failSave(error) + } + } + } + + private func applyError(_ event: BackendEvent) { + error = BackendErrorViewModel(event: event) + switch event.code { + case "auth_failed": + if let profileID = activeOperation?.profileID { + Task { await appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) } + } + state = .authFailed + case "unsupported_device": + state = .unsupported + default: + state = .failed + } + clearPendingOperation() + } + + private func failFromResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + state = .failed + clearPendingOperation() + } + + private func failContract(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "configure", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .failed + clearPendingOperation() + } + + private func failSave(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "device-profile", + code: "profile_save_failed", + message: error.localizedDescription + ) + state = .failed + clearPendingOperation() + } + + private func clearPendingOperation() { + activeOperation = nil + pendingProfile = nil + pendingDraft = nil + pendingPassword = nil + } + + private func applyDraft(_ draft: DeviceProfileEditorDraft) { + isApplyingDraft = true + self.draft = draft + isApplyingDraft = false + } + + private func markDirtyAfterDraftChange() { + guard !isApplyingDraft, !isRunning else { + return + } + error = nil + validationErrors = [] + savedProfile = nil + state = .dirty + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift index d1a9fef3..b9559775 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift @@ -108,13 +108,15 @@ final class DeviceRegistryStore: ObservableObject { configuredDevice: ConfiguredDeviceState, discoveredDevice: DiscoveredDevice?, passwordState: DevicePasswordState, - preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased(), + existingProfileID: DeviceProfile.ID? = nil ) async -> DeviceProfile { await repository.makeConfiguredDeviceProfile( configuredDevice: configuredDevice, discoveredDevice: discoveredDevice, passwordState: passwordState, - preferredID: preferredID + preferredID: preferredID, + existingProfileID: existingProfileID ) } @@ -302,9 +304,11 @@ private actor DeviceRegistryRepository { configuredDevice: ConfiguredDeviceState, discoveredDevice: DiscoveredDevice?, passwordState: DevicePasswordState, - preferredID: DeviceProfile.ID + preferredID: DeviceProfile.ID, + existingProfileID: DeviceProfile.ID? = nil ) -> DeviceProfile { - let existing = matchingProfile(host: configuredDevice.host, bonjourFullname: discoveredDevice?.fullname) + let existing = existingProfileID.flatMap { id in profiles.first { $0.id == id } } + ?? matchingProfile(host: configuredDevice.host, bonjourFullname: discoveredDevice?.fullname) var profile = DeviceProfile.make( id: preferredID, configuredDevice: configuredDevice, @@ -327,7 +331,8 @@ private actor DeviceRegistryRepository { configuredDevice: configuredDevice, discoveredDevice: discoveredDevice, passwordState: passwordState, - preferredID: preferredID + preferredID: preferredID, + existingProfileID: nil ) return try saveProfileMergingDuplicates(profile) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift index 6dfd0b55..e26a9633 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift @@ -189,7 +189,7 @@ final class MaintenanceStore: ObservableObject { } var mountWaitValue: Int? { - nonNegativeInteger(mountWait) + ValueParsers.nonNegativeInteger(mountWait) } var selectedFsckTarget: FsckTargetViewModel? { @@ -827,14 +827,6 @@ final class MaintenanceStore: ObservableObject { } } - private func nonNegativeInteger(_ text: String) -> Int? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let value = Int(trimmed), value >= 0 else { - return nil - } - return value - } - private func startRun( operation: String, params: [String: JSONValue], diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 05d7d3d7..2e3d8994 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -222,6 +222,23 @@ "password.error.missing" = "Password is missing."; "password.error.required" = "Password is required."; "password.error.unreadable_keychain_item" = "Keychain returned an unreadable password."; +"profile_editor.display_name" = "Display Name"; +"profile_editor.error.duplicate_host" = "Another saved Time Capsule already uses this host."; +"profile_editor.error.host_required" = "Host is required."; +"profile_editor.error.mount_wait_invalid" = "Mount wait must be a non-negative integer."; +"profile_editor.error.password_required" = "A saved password is required to change the host."; +"profile_editor.reset" = "Reset"; +"profile_editor.save" = "Save Profile"; +"profile_editor.state.auth_failed" = "Password Rejected"; +"profile_editor.state.clean" = "Saved"; +"profile_editor.state.dirty" = "Unsaved Changes"; +"profile_editor.state.failed" = "Failed"; +"profile_editor.state.invalid" = "Needs Changes"; +"profile_editor.state.reconfiguring" = "Reconfiguring"; +"profile_editor.state.saved" = "Saved"; +"profile_editor.state.saving" = "Saving"; +"profile_editor.state.unsupported" = "Unsupported"; +"profile_editor.title" = "Device Profile"; "readiness.blocked.title" = "TimeCapsuleSMB cannot start"; "readiness.state.checking_capabilities" = "Checking helper"; "readiness.state.resolving_bundle" = "Preparing app runtime"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ValueParsers.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ValueParsers.swift new file mode 100644 index 00000000..b8b32903 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ValueParsers.swift @@ -0,0 +1,11 @@ +import Foundation + +enum ValueParsers { + static func nonNegativeInteger(_ text: String) -> Int? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Int(trimmed), value >= 0 else { + return nil + } + return value + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index 3aab8952..642bb48a 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -58,6 +58,171 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(fixture.appStore.dashboardSummary(for: warning).primaryAction, .viewCheckup) } + func testDashboardSessionsAreIsolatedByProfile() async throws { + let fixture = try await makeFixture(responses: []) + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + + let firstSession = dashboard.session(for: first) + firstSession.selectedTab = .maintenance + firstSession.replacementPassword = "draft" + firstSession.deployStore.mountWait = "77" + firstSession.maintenanceStore.selectedWorkflow = .fsck + + let secondSession = dashboard.session(for: second) + + XCTAssertFalse(firstSession === secondSession) + XCTAssertEqual(secondSession.selectedTab, .overview) + XCTAssertEqual(secondSession.replacementPassword, "") + XCTAssertEqual(secondSession.deployStore.mountWait, "30") + XCTAssertEqual(secondSession.maintenanceStore.selectedWorkflow, .activate) + } + + func testSessionDefaultsComeFromProfileSettingsAndDoNotResetOnSnapshotUpdates() async throws { + let fixture = try await makeFixture(responses: []) + var profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + profile.settings = DeviceProfileSettings(nbnsEnabled: false, debugLogging: true, mountWaitSeconds: 45) + profile = try await fixture.registry.updateProfile(profile) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) + + XCTAssertEqual(session.deployStore.nbnsEnabled, false) + XCTAssertEqual(session.deployStore.debugLogging, true) + XCTAssertEqual(session.deployStore.mountWait, "45") + XCTAssertEqual(session.maintenanceStore.mountWait, "45") + + session.deployStore.mountWait = "12" + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 100), + state: .passed, + passCount: 1, + warnCount: 0, + failCount: 0, + summary: "healthy" + ), for: profile.id) + + XCTAssertEqual(session.deployStore.mountWait, "12") + } + + func testProfileEditorSaveAppliesSettingsBackToSessionDefaults() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) + + session.profileEditorStore.draft.nbnsEnabled = false + session.profileEditorStore.draft.debugLogging = true + session.profileEditorStore.draft.mountWaitSeconds = "64" + + await session.profileEditorStore.save(profile: profile) + + XCTAssertEqual(session.profileEditorStore.state, .saved) + XCTAssertEqual(session.deployStore.nbnsEnabled, false) + XCTAssertEqual(session.deployStore.debugLogging, true) + XCTAssertEqual(session.deployStore.mountWait, "64") + XCTAssertEqual(session.maintenanceStore.mountWait, "64") + } + + func testDeletingProfilePrunesInactiveSession() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + _ = dashboard.session(for: profile) + XCTAssertTrue(dashboard.hasSession(for: profile.id)) + + try await fixture.registry.delete(profile) + + try await waitUntilStoreState { !dashboard.hasSession(for: profile.id) } + } + + func testDeletedProfileSessionStaysUntilStartedOperationFinishes() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ], delayNanoseconds: 150_000_000) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) + + session.runCheckup(profile: profile) + try await waitUntilStoreState { fixture.appStore.backend.isRunning } + try await fixture.registry.delete(profile) + + XCTAssertTrue(dashboard.hasSession(for: profile.id)) + try await waitUntilStoreState { !fixture.appStore.backend.isRunning } + try await waitUntilStoreState { !dashboard.hasSession(for: profile.id) } + } + + func testOperationRunningOnAnotherDeviceRejectsNewSessionOperation() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ], delayNanoseconds: 200_000_000) + ]) + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + try fixture.passwordStore.save("pw1", for: first.keychainAccount) + try fixture.passwordStore.save("pw2", for: second.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let firstSession = dashboard.session(for: first) + let secondSession = dashboard.session(for: second) + + firstSession.runCheckup(profile: first) + try await waitUntilStoreState { fixture.appStore.backend.isRunning } + secondSession.runCheckup(profile: second) + + XCTAssertEqual(secondSession.doctorStore.state, .runFailed) + XCTAssertEqual(secondSession.doctorStore.error?.code, "operation_rejected") + XCTAssertEqual(fixture.runner.calls.count, 1) + } + func testDashboardOperationsUpdateLastCheckupAndDeploySnapshots() async throws { let fixture = try await makeFixture(responses: [ .init(events: [ @@ -82,21 +247,22 @@ final class DashboardStoreTests: XCTestCase { try fixture.passwordStore.save("pw", for: profile.keychainAccount) fixture.appStore.select(profile) let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) - dashboard.runCheckup(profile: profile) + session.runCheckup(profile: profile) - try await waitUntilStoreState { dashboard.doctorStore.state == .warning } + try await waitUntilStoreState { session.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) + session.runInstallPlan(profile: checked) + try await waitUntilStoreState { session.deployStore.state == .planReady } + session.runInstall(profile: checked) - try await waitUntilStoreState { dashboard.deployStore.state == .deployed } + try await waitUntilStoreState { session.deployStore.state == .deployed } let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) XCTAssertEqual(installed.lastDeploy?.state, .deployed) XCTAssertEqual(installed.lastDeploy?.payloadFamily, "netbsd6_samba4") @@ -129,12 +295,13 @@ final class DashboardStoreTests: XCTestCase { try fixture.passwordStore.save("pw", for: first.keychainAccount) fixture.appStore.select(first) let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: first) - dashboard.runCheckup(profile: first) + session.runCheckup(profile: first) fixture.appStore.select(second) try await waitUntilStoreState { - dashboard.doctorStore.state == .passed + session.doctorStore.state == .passed && fixture.registry.profile(id: first.id)?.lastCheckup?.state == .passed } XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastCheckup?.state, .passed) @@ -165,13 +332,14 @@ final class DashboardStoreTests: XCTestCase { try fixture.passwordStore.save("pw", for: first.keychainAccount) fixture.appStore.select(first) let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: first) - dashboard.runInstallPlan(profile: first) - try await waitUntilStoreState { dashboard.deployStore.state == .planReady } - dashboard.runInstall(profile: first) + session.runInstallPlan(profile: first) + try await waitUntilStoreState { session.deployStore.state == .planReady } + session.runInstall(profile: first) fixture.appStore.select(second) - try await waitUntilStoreState { dashboard.deployStore.state == .deployed } + try await waitUntilStoreState { session.deployStore.state == .deployed } XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastDeploy?.state, .deployed) XCTAssertNil(fixture.registry.profile(id: second.id)?.lastDeploy) } @@ -185,10 +353,11 @@ final class DashboardStoreTests: XCTestCase { preferredID: "device-one" ) let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) - dashboard.runCheckup(profile: profile) + session.runCheckup(profile: profile) - XCTAssertEqual(dashboard.passwordError, "Password is required.") + XCTAssertEqual(session.passwordError, "Password is required.") try await waitUntilStoreState { fixture.registry.profile(id: profile.id)?.passwordState == .missing } @@ -208,10 +377,11 @@ final class DashboardStoreTests: XCTestCase { ) try fixture.passwordStore.save("bad-password", for: profile.keychainAccount) let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) - dashboard.runCheckup(profile: profile) + session.runCheckup(profile: profile) - try await waitUntilStoreState { dashboard.doctorStore.state == .runFailed } + try await waitUntilStoreState { session.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) } @@ -225,36 +395,37 @@ final class DashboardStoreTests: XCTestCase { preferredID: "device-one" ) let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) let error = BackendErrorViewModel(operation: "doctor", code: "operation_failed", message: "Needs recovery.") - XCTAssertTrue(dashboard.handleRecoveryAction( + XCTAssertTrue(session.handleRecoveryAction( RecoveryAction(title: "Run Disk Repair", kind: .diskRepair), error: error, profile: profile )) - XCTAssertEqual(dashboard.selectedTab, .maintenance) - XCTAssertEqual(dashboard.maintenanceStore.selectedWorkflow, .fsck) + XCTAssertEqual(session.selectedTab, .maintenance) + XCTAssertEqual(session.maintenanceStore.selectedWorkflow, .fsck) - XCTAssertTrue(dashboard.handleRecoveryAction( + XCTAssertTrue(session.handleRecoveryAction( RecoveryAction(title: "Repair File Metadata", kind: .metadataRepair), error: error, profile: profile )) - XCTAssertEqual(dashboard.maintenanceStore.selectedWorkflow, .repairXattrs) + XCTAssertEqual(session.maintenanceStore.selectedWorkflow, .repairXattrs) - XCTAssertTrue(dashboard.handleRecoveryAction( + XCTAssertTrue(session.handleRecoveryAction( RecoveryAction(title: "Start SMB", kind: .startSMB), error: error, profile: profile )) - XCTAssertEqual(dashboard.maintenanceStore.selectedWorkflow, .activate) + XCTAssertEqual(session.maintenanceStore.selectedWorkflow, .activate) - XCTAssertTrue(dashboard.handleRecoveryAction( + XCTAssertTrue(session.handleRecoveryAction( RecoveryAction(title: "Replace Password", kind: .replacePassword), error: error, profile: profile )) - XCTAssertEqual(dashboard.selectedTab, .overview) + XCTAssertEqual(session.selectedTab, .overview) } func testRecoveryRunCheckupAndInstallActionsStartBackendOperations() async throws { @@ -276,9 +447,10 @@ final class DashboardStoreTests: XCTestCase { ) try fixture.passwordStore.save("pw", for: profile.keychainAccount) let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) let error = BackendErrorViewModel(operation: "deploy", code: "operation_failed", message: "Needs recovery.") - XCTAssertTrue(dashboard.handleRecoveryAction( + XCTAssertTrue(session.handleRecoveryAction( RecoveryAction(title: "Run Checkup", kind: .runCheckup), error: error, profile: profile @@ -286,9 +458,9 @@ final class DashboardStoreTests: XCTestCase { 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) + XCTAssertEqual(session.selectedTab, .checkup) - XCTAssertTrue(dashboard.handleRecoveryAction( + XCTAssertTrue(session.handleRecoveryAction( RecoveryAction(title: "Install SMB", kind: .installSMB), error: error, profile: profile @@ -297,7 +469,7 @@ final class DashboardStoreTests: XCTestCase { 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) + XCTAssertEqual(session.selectedTab, .install) } func testRecoveryRetryUsesFailedOperation() async throws { @@ -316,9 +488,10 @@ final class DashboardStoreTests: XCTestCase { ) try fixture.passwordStore.save("pw", for: profile.keychainAccount) let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) let doctorError = BackendErrorViewModel(operation: "doctor", code: "operation_failed", message: "Doctor failed.") - XCTAssertTrue(dashboard.handleRecoveryAction( + XCTAssertTrue(session.handleRecoveryAction( RecoveryAction(title: "Retry", kind: .retry), error: doctorError, profile: profile @@ -326,7 +499,7 @@ final class DashboardStoreTests: XCTestCase { try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") - XCTAssertEqual(dashboard.selectedTab, .checkup) + XCTAssertEqual(session.selectedTab, .checkup) } func testNonActionableRecoveryKindsReturnFalse() async throws { @@ -338,14 +511,15 @@ final class DashboardStoreTests: XCTestCase { preferredID: "device-one" ) let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) let error = BackendErrorViewModel(operation: "validate-install", code: "operation_failed", message: "Needs diagnostics.") - XCTAssertFalse(dashboard.handleRecoveryAction( + XCTAssertFalse(session.handleRecoveryAction( RecoveryAction(title: "Open Diagnostics", kind: .diagnostics), error: error, profile: profile )) - XCTAssertFalse(dashboard.handleRecoveryAction( + XCTAssertFalse(session.handleRecoveryAction( RecoveryAction(title: "Unknown", kind: .generic), error: error, profile: profile diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift new file mode 100644 index 00000000..423b1430 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift @@ -0,0 +1,272 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceProfileEditorStoreTests: XCTestCase { + func testStateAndValidationInventoriesAreExplicit() { + XCTAssertEqual(DeviceProfileEditorState.allCases.map(\.rawValue), [ + "clean", + "dirty", + "invalid", + "saving", + "reconfiguring", + "saved", + "authFailed", + "unsupported", + "failed" + ]) + XCTAssertEqual(DeviceProfileEditorValidationError.allCases.map(\.rawValue), [ + "hostRequired", + "duplicateHost", + "mountWaitInvalid", + "passwordRequired" + ]) + } + + func testMountWaitValidationAcceptsZeroAndPositiveIntegersOnly() throws { + var draft = DeviceProfileEditorDraft( + displayName: "Office", + host: "10.0.0.2", + nbnsEnabled: true, + debugLogging: false, + mountWaitSeconds: "0" + ) + XCTAssertEqual(try draft.validatedSettings().mountWaitSeconds, 0) + + draft.mountWaitSeconds = "45" + XCTAssertEqual(try draft.validatedSettings().mountWaitSeconds, 45) + + for invalid in ["", "-1", "1.5", "abc"] { + draft.mountWaitSeconds = invalid + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? DeviceProfileEditorValidationError, .mountWaitInvalid) + } + } + } + + func testUnchangedHostSaveUpdatesProfileSettingsWithoutBackendConfigure() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.displayName = "Media Capsule" + store.draft.nbnsEnabled = false + store.draft.debugLogging = true + store.draft.mountWaitSeconds = "45" + + await store.save(profile: profile) + + let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(store.state, .saved) + XCTAssertEqual(saved.displayName, "Media Capsule") + XCTAssertEqual(saved.host, "root@10.0.0.2") + XCTAssertEqual(saved.settings, DeviceProfileSettings(nbnsEnabled: false, debugLogging: true, mountWaitSeconds: 45)) + XCTAssertEqual(fixture.runner.calls, []) + } + + func testBlankDisplayNameIsAllowedAndFallsBackThroughTitlePolicy() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "TimeCapsule8,119"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.displayName = "" + + await store.save(profile: profile) + + let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(store.state, .saved) + XCTAssertEqual(saved.displayName, "") + XCTAssertEqual(saved.title, "TimeCapsule8,119") + } + + func testInvalidHostDuplicateHostAndInvalidMountWaitSaveNothing() async throws { + let fixture = try await makeFixture(responses: []) + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + _ = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + let store = DeviceProfileEditorStore(profile: first, appStore: fixture.appStore) + + store.draft.host = " " + await store.save(profile: first) + XCTAssertEqual(store.state, .invalid) + XCTAssertEqual(store.validationErrors, [.hostRequired]) + + store.draft.host = "10.0.0.3" + await store.save(profile: first) + XCTAssertEqual(store.state, .invalid) + XCTAssertEqual(store.validationErrors, [.duplicateHost]) + + store.draft.host = first.host + store.draft.mountWaitSeconds = "bad" + await store.save(profile: first) + XCTAssertEqual(store.state, .invalid) + XCTAssertEqual(store.validationErrors, [.mountWaitInvalid]) + XCTAssertEqual(fixture.runner.calls, []) + } + + func testChangedHostRequiresSavedPassword() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.host = "10.0.0.9" + await store.save(profile: profile) + + XCTAssertEqual(store.state, .invalid) + XCTAssertEqual(store.validationErrors, [.passwordRequired]) + XCTAssertEqual(fixture.runner.calls, []) + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.host, "10.0.0.2") + } + + func testChangedHostRunsConfigureWithExistingProfileContextAndPreservesProfileData() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "configure", + ok: true, + payload: testConfigurePayload(host: "root@10.0.0.9", syap: "119", model: "TimeCapsule8,119") + ) + ]) + ]) + var profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 100), + state: .passed, + passCount: 1, + warnCount: 0, + failCount: 0, + summary: "healthy" + ), for: profile.id) + await fixture.registry.updateDeploy(DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 110), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "installed" + ), for: profile.id) + profile = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.displayName = "Updated Capsule" + store.draft.host = "10.0.0.9" + store.draft.nbnsEnabled = false + store.draft.debugLogging = true + store.draft.mountWaitSeconds = "60" + + await store.save(profile: profile) + + try await waitUntilStoreState { store.state == .saved } + let call = try XCTUnwrap(fixture.runner.calls.first) + XCTAssertEqual(call.operation, "configure") + XCTAssertEqual(call.context, profile.runtimeContext) + XCTAssertEqual(call.params["config"], .string(profile.configPath)) + XCTAssertEqual(call.params["host"], .string("root@10.0.0.9")) + XCTAssertEqual(call.params["password"], .string("pw")) + XCTAssertEqual(call.params["persist_password"], .bool(false)) + + let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(saved.id, profile.id) + XCTAssertEqual(saved.keychainAccount, profile.keychainAccount) + XCTAssertEqual(saved.displayName, "Updated Capsule") + XCTAssertEqual(saved.host, "root@10.0.0.9") + XCTAssertEqual(saved.lastCheckup?.state, .passed) + XCTAssertEqual(saved.lastDeploy?.state, .deployed) + XCTAssertEqual(saved.settings, DeviceProfileSettings(nbnsEnabled: false, debugLogging: true, mountWaitSeconds: 60)) + } + + func testAuthFailureAndUnsupportedDeviceSaveNothing() async throws { + let auth = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "auth_failed", message: "bad password") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let authProfile = try await auth.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try auth.passwordStore.save("bad", for: authProfile.keychainAccount) + let authStore = DeviceProfileEditorStore(profile: authProfile, appStore: auth.appStore) + authStore.draft.host = "10.0.0.9" + + await authStore.save(profile: authProfile) + + try await waitUntilStoreState { authStore.state == .authFailed } + XCTAssertEqual(auth.registry.profile(id: authProfile.id)?.host, "10.0.0.2") + XCTAssertEqual(auth.registry.profile(id: authProfile.id)?.passwordState, .invalid) + + let unsupported = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "unsupported_device", message: "unsupported") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let unsupportedProfile = try await unsupported.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try unsupported.passwordStore.save("pw", for: unsupportedProfile.keychainAccount) + let unsupportedStore = DeviceProfileEditorStore(profile: unsupportedProfile, appStore: unsupported.appStore) + unsupportedStore.draft.host = "10.0.0.9" + + await unsupportedStore.save(profile: unsupportedProfile) + + try await waitUntilStoreState { unsupportedStore.state == .unsupported } + XCTAssertEqual(unsupported.registry.profile(id: unsupportedProfile.id)?.host, "10.0.0.2") + } + + private func makeFixture(responses: [StoreTestRunner.Response]) async throws -> ( + appStore: AppStore, + registry: DeviceRegistryStore, + passwordStore: InMemoryPasswordStore, + runner: StoreTestRunner + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let runner = StoreTestRunner(responses: responses) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let passwordStore = InMemoryPasswordStore() + let appStore = AppStore( + appReadinessStore: AppReadinessStore(backend: coordinator.backend), + deviceRegistry: registry, + operationCoordinator: coordinator, + passwordStore: passwordStore + ) + return (appStore, registry, passwordStore, runner) + } +} diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index ec4941f6..273c4fa9 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -560,7 +560,8 @@ class ConfigProfile: validated_keys=MANAGED_VALIDATED_KEYS, ), "doctor": ConfigProfile( - required_file_values=(*MANAGED_REQUIRED_FILE_KEYS, "TC_PASSWORD"), + required_file_values=MANAGED_REQUIRED_FILE_KEYS, + required_values=("TC_PASSWORD",), validated_keys=MANAGED_VALIDATED_KEYS, ), "uninstall": ConfigProfile( diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 2418f5ad..275f86fe 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -681,6 +681,43 @@ def test_doctor_passes_bonjour_timeout_to_checks(self) -> None: self.assertEqual(rc, 0) self.assertEqual(checks.call_args.kwargs["bonjour_timeout"], 2.75) + def test_doctor_uses_request_credentials_without_requiring_saved_password(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values( + { + "TC_HOST": "root@10.0.0.2", + "TC_SSH_OPTS": "-o foo", + }, + file_values={ + "TC_HOST": "root@10.0.0.2", + "TC_SSH_OPTS": "-o foo", + }, + ) + + def fake_run_doctor_checks(config_arg, **_kwargs): + self.assertEqual(config_arg.get("TC_PASSWORD"), "keychain-pw") + self.assertFalse(config_arg.has_file_value("TC_PASSWORD")) + return [], False + + with mock.patch("timecapsulesmb.app.ops.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.run_doctor_checks", side_effect=fake_run_doctor_checks): + rc = service.run_api_request( + { + "operation": "doctor", + "params": { + "skip_ssh": True, + "credentials": {"password": "keychain-pw"}, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertTrue(result["ok"]) + self.assertNotIn("keychain-pw", json.dumps(collector.events)) + def test_doctor_fatal_returns_nonzero_result_without_error_event(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) diff --git a/tests/test_config.py b/tests/test_config.py index f310863c..2375276c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -545,6 +545,28 @@ def test_flash_profile_requires_password(self) -> None: self.assertEqual(errors[0].kind, "missing_key") self.assertEqual(errors[0].key, "TC_PASSWORD") + def test_doctor_profile_accepts_request_scoped_password(self) -> None: + values = dict(DEFAULTS) + values["TC_HOST"] = "root@10.0.0.2" + values["TC_PASSWORD"] = "pw" + file_values = dict(values) + file_values.pop("TC_PASSWORD", None) + config = AppConfig.from_values(values, file_values=file_values) + + self.assertEqual(validate_app_config(config, profile="doctor"), []) + + def test_doctor_profile_still_requires_effective_password(self) -> None: + values = dict(DEFAULTS) + values["TC_HOST"] = "root@10.0.0.2" + file_values = dict(values) + file_values.pop("TC_PASSWORD", None) + config = AppConfig.from_values(values, file_values=file_values) + + errors = validate_app_config(config, profile="doctor") + + self.assertEqual(errors[0].kind, "missing_key") + self.assertEqual(errors[0].key, "TC_PASSWORD") + def test_validate_app_config_ignores_stale_device_model_syap_pair(self) -> None: values = dict(DEFAULTS) values["TC_HOST"] = "root@10.0.0.2" From d31d9fe76c23aed16d51dee2216cec56876f6d77 Mon Sep 17 00:00:00 2001 From: James Chang Date: Thu, 21 May 2026 05:14:19 -0700 Subject: [PATCH 023/129] Build dashboard and install/update presentation workflows --- .../{ => App}/AppStore.swift | 42 +- .../{ => App}/BundleLayout.swift | 0 .../{ => App}/Localization.swift | 0 .../TimeCapsuleSMBApp/App/URLOpening.swift | 16 + .../{ => Backend}/BackendClient.swift | 0 .../BackendPayloadDecoding.swift | 0 .../{ => Backend}/BackendPayloads.swift | 0 .../{ => Backend}/BackendViewModels.swift | 0 .../{ => Backend}/HelperLocator.swift | 0 .../{ => Backend}/HelperPipeReader.swift | 0 .../{ => Backend}/HelperRequestWriter.swift | 0 .../{ => Backend}/HelperRunner.swift | 0 .../{ => Backend}/Models.swift | 0 .../{ => Backend}/OperationParams.swift | 0 .../{ => Backend}/OutputLineParser.swift | 0 .../{ => Backend}/PendingConfirmation.swift | 0 .../TimeCapsuleSMBApp/ConnectView.swift | 188 --- .../ConnectionWorkflowStore.swift | 389 ------ .../TimeCapsuleSMBApp/ContentView.swift | 1228 ----------------- .../TimeCapsuleSMBApp/DeployView.swift | 201 --- .../TimeCapsuleSMBApp/DoctorView.swift | 150 -- .../TimeCapsuleSMBApp/MaintenanceView.swift | 282 ---- .../{ => Policies}/DeviceStatusPolicy.swift | 0 .../HostCompatibilityPolicy.swift | 0 .../{ => Policies}/RecoveryActionMapper.swift | 0 .../{ => Policies}/ValueParsers.swift | 0 .../Profiles/ConfiguredDeviceModels.swift | 97 ++ .../ConfiguredDeviceProfileSaver.swift | 0 .../{ => Profiles}/DeviceProfile.swift | 0 .../DeviceProfileEditableFields.swift | 6 + .../DeviceProfileEditorStore.swift | 6 +- .../{ => Profiles}/DeviceProfileTraits.swift | 0 .../{ => Profiles}/DeviceRegistryStore.swift | 0 .../{ => Profiles}/PasswordStore.swift | 0 .../TimeCapsuleSMBApp/ReadinessStore.swift | 216 --- .../TimeCapsuleSMBApp/ReadinessView.swift | 198 --- .../Resources/en.lproj/Localizable.strings | 58 + .../Views/AddDevice/AddDeviceView.swift | 182 +++ .../Components}/ErrorRecoveryView.swift | 0 .../{ => Views/Components}/SharedViews.swift | 0 .../Views/Components/ToolbarIconButton.swift | 36 + .../Views/Dashboard/AdvancedTab.swift | 94 ++ .../Views/Dashboard/CheckupTab.swift | 62 + .../Views/Dashboard/DeviceDashboardView.swift | 41 + .../Dashboard}/FlashBootHookView.swift | 0 .../Views/Dashboard/InstallTab.swift | 242 ++++ .../Views/Dashboard/MaintenanceTab.swift | 169 +++ .../Views/Dashboard/OverviewTab.swift | 200 +++ .../Views/Diagnostics/AppReadinessViews.swift | 180 +++ .../{ => Views/Shell}/ActivityView.swift | 0 .../Views/Shell/ContentView.swift | 242 ++++ .../Views/Shell/DeviceListOverviewView.swift | 46 + .../{ => Views/Shell}/SidebarView.swift | 0 .../{ => Workflows}/ActivityStore.swift | 0 .../{ => Workflows}/AddDeviceFlowStore.swift | 0 .../{ => Workflows}/AppReadinessStore.swift | 0 .../DashboardOverviewPresentation.swift | 464 +++++++ .../DashboardPresentation.swift | 85 +- .../Workflows/DashboardStore.swift | 51 + .../{ => Workflows}/DeployWorkflowStore.swift | 0 .../DeviceDashboardSession.swift} | 171 +-- .../Workflows/DeviceDashboardTab.swift | 26 + .../{ => Workflows}/DoctorStore.swift | 0 .../{ => Workflows}/FlashWorkflowStore.swift | 0 .../Workflows/InstallPresentation.swift | 228 +++ .../{ => Workflows}/MaintenanceStore.swift | 0 .../OperationCoordinator.swift | 0 .../{ => Workflows}/OperationTimeline.swift | 0 .../AddDeviceFlowStoreTests.swift | 71 + .../ConnectionWorkflowStoreTests.swift | 428 ------ .../DashboardPresentationTests.swift | 226 ++- .../DashboardStoreTests.swift | 132 ++ .../ReadinessStoreTests.swift | 190 --- .../StoreTestSupport.swift | 8 +- 74 files changed, 3000 insertions(+), 3651 deletions(-) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => App}/AppStore.swift (81%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => App}/BundleLayout.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => App}/Localization.swift (100%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/URLOpening.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/BackendClient.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/BackendPayloadDecoding.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/BackendPayloads.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/BackendViewModels.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/HelperLocator.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/HelperPipeReader.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/HelperRequestWriter.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/HelperRunner.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/Models.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/OperationParams.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/OutputLineParser.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Backend}/PendingConfirmation.swift (100%) delete mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift delete mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift delete mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift delete mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift delete mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift delete mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Policies}/DeviceStatusPolicy.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Policies}/HostCompatibilityPolicy.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Policies}/RecoveryActionMapper.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Policies}/ValueParsers.swift (100%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Profiles}/ConfiguredDeviceProfileSaver.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Profiles}/DeviceProfile.swift (100%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditableFields.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Profiles}/DeviceProfileEditorStore.swift (98%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Profiles}/DeviceProfileTraits.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Profiles}/DeviceRegistryStore.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Profiles}/PasswordStore.swift (100%) delete mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift delete mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Views/Components}/ErrorRecoveryView.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Views/Components}/SharedViews.swift (100%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ToolbarIconButton.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Views/Dashboard}/FlashBootHookView.swift (100%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Views/Shell}/ActivityView.swift (100%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Views/Shell}/SidebarView.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/ActivityStore.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/AddDeviceFlowStore.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/AppReadinessStore.swift (100%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/DashboardPresentation.swift (60%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/DeployWorkflowStore.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{DashboardStore.swift => Workflows/DeviceDashboardSession.swift} (78%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/DoctorStore.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/FlashWorkflowStore.swift (100%) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/MaintenanceStore.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/OperationCoordinator.swift (100%) rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/{ => Workflows}/OperationTimeline.swift (100%) delete mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift delete mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift similarity index 81% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift index c622ae25..22f389c8 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift @@ -1,23 +1,6 @@ import Combine import Foundation -enum DashboardPrimaryAction: String, Equatable { - case addDevice - case replacePassword - case runCheckup - case installSMB - case viewCheckup - case openSMB -} - -struct DeviceDashboardSummary: Equatable { - let profile: DeviceProfile - let passwordState: DevicePasswordState - let displayStatus: DeviceDisplayStatus - let primaryAction: DashboardPrimaryAction - let hostWarning: HostCompatibilityWarning? -} - @MainActor final class AppStore: ObservableObject { @Published var selectedDeviceID: DeviceProfile.ID? @@ -149,33 +132,14 @@ final class AppStore: ObservableObject { await deviceRegistry.updatePasswordState(.available, for: profile.id) } - func updateSettings(_ settings: DeviceProfileSettings, for profile: DeviceProfile) async throws { - var updated = profile - updated.settings = settings - try await deviceRegistry.updateProfile(updated) - } - @discardableResult - func saveProfileEdits(profile: DeviceProfile, draft: DeviceProfileEditorDraft) async throws -> DeviceProfile { + func saveProfileEdits(profile: DeviceProfile, fields: DeviceProfileEditableFields) async throws -> DeviceProfile { var updated = profile - updated.displayName = draft.displayName - updated.host = draft.trimmedHost - updated.settings = try draft.validatedSettings() + updated.displayName = fields.displayName + updated.settings = fields.settings return try await deviceRegistry.updateProfile(updated) } - func rename(_ profile: DeviceProfile, displayName: String) async throws { - var updated = profile - updated.displayName = displayName - try await deviceRegistry.updateProfile(updated) - } - - func updateHost(_ profile: DeviceProfile, host: String) async throws { - var updated = profile - updated.host = host - try await deviceRegistry.updateProfile(updated) - } - func forget(_ profile: DeviceProfile) async throws { try passwordStore.deletePassword(for: profile.keychainAccount) try await deviceRegistry.delete(profile) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BundleLayout.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BundleLayout.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/URLOpening.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/URLOpening.swift new file mode 100644 index 00000000..4c06d7ef --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/URLOpening.swift @@ -0,0 +1,16 @@ +import Foundation +#if canImport(AppKit) +import AppKit +#endif + +protocol URLOpening { + func open(_ url: URL) +} + +struct WorkspaceURLOpener: URLOpening { + func open(_ url: URL) { + #if canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloadDecoding.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloadDecoding.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloadDecoding.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloadDecoding.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendViewModels.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendViewModels.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendViewModels.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendViewModels.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperLocator.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperLocator.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperPipeReader.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperPipeReader.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperPipeReader.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperPipeReader.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRequestWriter.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRequestWriter.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRunner.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRunner.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/Models.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/Models.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OutputLineParser.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OutputLineParser.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift deleted file mode 100644 index a0e01a95..00000000 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift +++ /dev/null @@ -1,188 +0,0 @@ -import SwiftUI - -struct ConnectView: View { - @ObservedObject var store: ConnectionWorkflowStore - @Binding var password: String - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("panel.connect")) - .font(.title2.weight(.semibold)) - - HStack { - TextField(L10n.string("field.host"), text: $store.manualHost) - SecureField(L10n.string("field.password"), text: $password) - TextField(L10n.string("field.bonjour_timeout"), text: $store.bonjourTimeout) - .frame(width: 180) - } - - Toggle(L10n.string("toggle.enable_debug_logging"), isOn: $store.debugLogging) - - HStack { - Button { - store.runDiscover() - } label: { - Label(L10n.string("button.discover"), systemImage: "network") - } - .disabled(store.isRunning || store.bonjourTimeoutValue == nil) - - Button { - store.runConfigure(password: password) - } label: { - Label(L10n.string("button.configure"), systemImage: "lock.open") - } - .disabled(!store.canConfigure(password: password)) - - Label(store.state.title, systemImage: statusIcon) - .foregroundStyle(statusColor) - } - - if let stage = store.currentStage { - HStack(spacing: 8) { - Text(stage.stage) - .font(.system(.caption, design: .monospaced)) - if let description = stage.description { - Text(description) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - if !store.devices.isEmpty { - VStack(alignment: .leading, spacing: 6) { - ForEach(store.devices) { device in - Button { - store.select(device) - } label: { - DeviceRow( - device: device, - selected: store.selectedDeviceID == device.id - ) - } - .buttonStyle(.plain) - } - } - } - - if let configuredDevice = store.configuredDevice { - ConfiguredDeviceView(device: configuredDevice) - } - - if let error = store.error { - ErrorBlock(error: error) - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var statusIcon: String { - switch store.state { - case .idle: - return "circle" - case .discovering, .configuring: - return "hourglass" - case .discoveryReady, .configured: - return "checkmark.circle" - case .discoveryEmpty: - return "magnifyingglass" - case .discoveryFailed, .configureFailed: - return "exclamationmark.triangle" - } - } - - private var statusColor: Color { - switch store.state { - case .discoveryReady, .configured: - return .green - case .discoveryFailed, .configureFailed: - return .red - default: - return .secondary - } - } -} - -private struct DeviceRow: View { - let device: DiscoveredDevice - let selected: Bool - - var body: some View { - HStack(spacing: 10) { - Image(systemName: selected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(selected ? Color.accentColor : Color.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(.body.weight(.medium)) - HStack(spacing: 8) { - if !device.host.isEmpty { - Text(device.host) - } - if !device.hostname.isEmpty { - Text(device.hostname) - } - } - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if let model = device.model { - Text(model) - .font(.caption) - .foregroundStyle(.secondary) - } else if let syap = device.syap { - Text("syAP \(syap)") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 6) - .padding(.horizontal, 8) - .background(selected ? Color.accentColor.opacity(0.12) : Color.clear) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } -} - -private struct ConfiguredDeviceView: View { - let device: ConfiguredDeviceState - - var body: some View { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { - GridRow { - Text("Configured Host") - .foregroundStyle(.secondary) - Text(device.host) - } - GridRow { - Text("Config") - .foregroundStyle(.secondary) - Text(device.configPath) - .lineLimit(1) - .truncationMode(.middle) - } - if let model = device.model { - GridRow { - Text("Model") - .foregroundStyle(.secondary) - Text(model) - } - } - if let syap = device.syap { - GridRow { - Text("syAP") - .foregroundStyle(.secondary) - Text(syap) - } - } - if let compatibility = device.compatibility { - GridRow { - Text("Payload") - .foregroundStyle(.secondary) - Text(compatibility.payloadFamily ?? "unknown") - } - } - } - .font(.caption) - } -} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift deleted file mode 100644 index 63fa38f8..00000000 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift +++ /dev/null @@ -1,389 +0,0 @@ -import Combine -import Foundation - -enum ConnectionWorkflowState: String, CaseIterable, Equatable { - case idle - case discovering - case discoveryReady - case discoveryEmpty - case discoveryFailed - case configuring - case configured - case configureFailed - - var title: String { - switch self { - case .idle: - return "Idle" - case .discovering: - return "Discovering" - case .discoveryReady: - return "Devices Found" - case .discoveryEmpty: - return "No Devices Found" - case .discoveryFailed: - return "Discovery Failed" - case .configuring: - return "Configuring" - case .configured: - return "Configured" - case .configureFailed: - return "Configure Failed" - } - } -} - -struct DiscoveredDevice: Identifiable, Equatable { - let id: String - let name: String - let host: String - let hostname: String - let addresses: [String] - let syap: String? - let model: String? - let rawRecord: JSONValue - - init(payload: DiscoveredDevicePayload, index: Int) { - self.id = payload.id.isEmpty ? "discovered-\(index)" : payload.id - self.name = payload.name.isEmpty ? (payload.hostname.isEmpty ? "AirPort Device" : payload.hostname) : payload.name - self.host = payload.host - self.hostname = payload.hostname - self.addresses = payload.addresses.isEmpty ? payload.ipv4 + payload.ipv6 : payload.addresses - self.syap = Self.nonEmpty(payload.syap) - self.model = Self.nonEmpty(payload.model) ?? Self.recordProperty(payload.selectedRecord, keys: ["model", "am"]) - self.rawRecord = payload.selectedRecord - } - - init(record: BonjourResolvedServicePayload, index: Int) { - let stableParts = [ - record.fullname, - record.serviceType, - record.name, - record.hostname, - record.ipv4.joined(separator: ","), - record.ipv6.joined(separator: ",") - ] - let stableID = stableParts - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .joined(separator: "|") - - self.id = stableID.isEmpty ? "discovered-\(index)" : stableID - self.name = record.name.isEmpty ? (record.hostname.isEmpty ? "AirPort Device" : record.hostname) : record.name - self.hostname = record.hostname - self.addresses = record.ipv4 + record.ipv6 - self.host = Self.displayHost(record) - self.syap = Self.nonEmpty(record.properties["syAP"] ?? record.properties["syap"]) - self.model = Self.nonEmpty(record.properties["model"] ?? record.properties["am"]) - self.rawRecord = record.jsonValue - } - - var discoveryModelText: String { - Self.nonEmpty(model) ?? "" - } - - private static func displayHost(_ record: BonjourResolvedServicePayload) -> String { - if let address = record.ipv4.first ?? record.ipv6.first { - return address - } - return record.hostname - } - - private static func recordProperty(_ record: JSONValue, keys: [String]) -> String? { - guard case .object(let values) = record, case .object(let properties)? = values["properties"] else { - return nil - } - for key in keys { - if case .string(let value)? = properties[key], let trimmed = nonEmpty(value) { - return trimmed - } - } - return nil - } - - private static func nonEmpty(_ value: String?) -> String? { - guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { - return nil - } - return trimmed - } -} - -struct ConfiguredDeviceState: Equatable { - let host: String - let configPath: String - let configureId: String - let sshAuthenticated: Bool - let syap: String? - let model: String? - let compatibility: DeviceCompatibilityPayload? - - init(payload: ConfigurePayload) { - self.host = payload.host - self.configPath = payload.configPath - self.configureId = payload.configureId - self.sshAuthenticated = payload.sshAuthenticated - self.syap = payload.deviceSyap ?? payload.device?.syap - self.model = payload.deviceModel ?? payload.device?.model - self.compatibility = payload.compatibility - } -} - -@MainActor -final class ConnectionWorkflowStore: ObservableObject { - @Published var manualHost = "" - @Published var bonjourTimeout = "6" - @Published var debugLogging = false - @Published private(set) var state: ConnectionWorkflowState = .idle - @Published private(set) var devices: [DiscoveredDevice] = [] - @Published var selectedDeviceID: DiscoveredDevice.ID? - @Published private(set) var configuredDevice: ConfiguredDeviceState? - @Published private(set) var error: BackendErrorViewModel? - @Published private(set) var currentStage: OperationStageState? - - let backend: BackendClient - - private var lastProcessedEventCount = 0 - private var cancellables: Set = [] - - convenience init() { - self.init(backend: BackendClient()) - } - - init(backend: BackendClient) { - self.backend = backend - backend.$events - .sink { [weak self] events in - Task { @MainActor in - self?.process(events) - } - } - .store(in: &cancellables) - } - - var events: [BackendEvent] { - backend.events - } - - var isRunning: Bool { - backend.isRunning - } - - var canCancel: Bool { - backend.canCancel - } - - var bonjourTimeoutValue: Double? { - nonNegativeDouble(bonjourTimeout) - } - - var selectedDevice: DiscoveredDevice? { - guard let selectedDeviceID else { - return nil - } - return devices.first { $0.id == selectedDeviceID } - } - - func canConfigure(password: String) -> Bool { - !backend.isRunning - && !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && (selectedDevice != nil || !manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - - func runDiscover() { - guard let timeout = bonjourTimeoutValue else { - failLocally(operation: "discover", state: .discoveryFailed, message: "Bonjour timeout must be a non-negative number.") - return - } - resetRunState(clearDevices: true, clearConfiguredDevice: true) - state = .discovering - backend.run(operation: "discover", params: OperationParams.discover(timeout: timeout)) - } - - func runConfigure(password: String) { - let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedPassword.isEmpty else { - failLocally(operation: "configure", state: .configureFailed, message: "Password is required.") - return - } - let selectedDevice = selectedDevice - let trimmedHost = manualHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard selectedDevice != nil || !trimmedHost.isEmpty else { - failLocally(operation: "configure", state: .configureFailed, message: "Choose a discovered device or enter a host.") - return - } - - resetRunState(clearDevices: false, clearConfiguredDevice: true) - state = .configuring - let params = OperationParams.configure( - host: trimmedHost, - selectedRecord: selectedDevice?.rawRecord, - password: password, - debugLogging: debugLogging - ) - backend.run(operation: "configure", params: params) - } - - func select(_ device: DiscoveredDevice) { - selectedDeviceID = device.id - } - - func clear() { - backend.clear() - lastProcessedEventCount = 0 - state = .idle - devices = [] - selectedDeviceID = nil - configuredDevice = nil - error = nil - currentStage = nil - } - - func cancel() { - backend.cancel() - } - - private func resetRunState(clearDevices: Bool, clearConfiguredDevice: Bool) { - backend.clear() - lastProcessedEventCount = 0 - error = nil - currentStage = nil - if clearDevices { - devices = [] - selectedDeviceID = nil - } - if clearConfiguredDevice { - configuredDevice = nil - } - } - - private func process(_ events: [BackendEvent]) { - if events.count < lastProcessedEventCount { - lastProcessedEventCount = 0 - } - guard events.count > lastProcessedEventCount else { - return - } - - for event in events.dropFirst(lastProcessedEventCount) { - handle(event) - } - lastProcessedEventCount = events.count - } - - private func handle(_ event: BackendEvent) { - guard event.operation == "discover" || event.operation == "configure" else { - return - } - - if let stage = OperationStageState(event: event) { - currentStage = stage - return - } - - if event.type == "error" { - applyError(event) - return - } - - guard event.type == "result" else { - return - } - - if event.ok == false { - applyFailureResult(event) - return - } - - switch event.operation { - case "discover": - applyDiscoverResult(event) - case "configure": - applyConfigureResult(event) - default: - break - } - } - - private func applyDiscoverResult(_ event: BackendEvent) { - do { - let payload = try event.decodePayload(DiscoverPayload.self) - let discoveredDevices = payload.devices.isEmpty - ? payload.resolved.enumerated().map { index, record in DiscoveredDevice(record: record, index: index) } - : payload.devices.enumerated().map { index, device in DiscoveredDevice(payload: device, index: index) } - devices = discoveredDevices - selectedDeviceID = discoveredDevices.count == 1 ? discoveredDevices[0].id : nil - error = nil - state = discoveredDevices.isEmpty ? .discoveryEmpty : .discoveryReady - } catch { - failContract(operation: "discover", state: .discoveryFailed, error: error) - } - } - - private func applyConfigureResult(_ event: BackendEvent) { - do { - let payload = try event.decodePayload(ConfigurePayload.self) - configuredDevice = ConfiguredDeviceState(payload: payload) - error = nil - state = .configured - } catch { - failContract(operation: "configure", state: .configureFailed, error: error) - } - } - - private func applyError(_ event: BackendEvent) { - error = BackendErrorViewModel(event: event) - switch event.operation { - case "discover": - state = .discoveryFailed - case "configure": - state = .configureFailed - default: - break - } - } - - private func applyFailureResult(_ event: BackendEvent) { - let message = event.payloadSummaryText ?? event.summary - error = BackendErrorViewModel( - operation: event.operation, - code: "operation_failed", - message: message - ) - switch event.operation { - case "discover": - state = .discoveryFailed - case "configure": - state = .configureFailed - default: - break - } - } - - private func failContract(operation: String, state: ConnectionWorkflowState, error: Error) { - self.error = BackendErrorViewModel( - operation: operation, - code: "contract_decode_failed", - message: error.localizedDescription - ) - self.state = state - } - - private func failLocally(operation: String, state: ConnectionWorkflowState, message: String) { - error = BackendErrorViewModel( - operation: operation, - code: "validation_failed", - message: message - ) - currentStage = nil - self.state = state - } - - private func nonNegativeDouble(_ text: String) -> Double? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let value = Double(trimmed), value.isFinite, value >= 0 else { - return nil - } - return value - } -} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift deleted file mode 100644 index 3869d3b3..00000000 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ /dev/null @@ -1,1228 +0,0 @@ -import AppKit -import SwiftUI - -public struct ContentView: View { - @StateObject private var appStore: AppStore - @StateObject private var addDeviceStore: AddDeviceFlowStore - @StateObject private var dashboardStore: DashboardStore - @State private var diagnosticsPresented = false - @State private var profilePendingDeletion: DeviceProfile? - @State private var deleteErrorMessage: String? - - @MainActor - public init() { - let appStore = AppStore() - _appStore = StateObject(wrappedValue: appStore) - _addDeviceStore = StateObject(wrappedValue: AddDeviceFlowStore( - coordinator: appStore.operationCoordinator, - registry: appStore.deviceRegistry, - passwordStore: appStore.passwordStore - )) - _dashboardStore = StateObject(wrappedValue: DashboardStore(appStore: appStore)) - } - - public var body: some View { - NavigationSplitView { - sidebar - } detail: { - VStack(spacing: 0) { - if case .blocked = appStore.appReadinessStore.state { - AppReadinessBlockedView(store: appStore.appReadinessStore) { - diagnosticsPresented = true - } - } else { - AppReadinessBannerView(store: appStore.appReadinessStore) { - diagnosticsPresented = true - } - detail - Divider() - ActivityCompactView( - activityStore: appStore.activityStore, - registry: appStore.deviceRegistry - ) - } - } - .toolbar { - ToolbarItemGroup { - ToolbarIconButton( - title: L10n.string("toolbar.add"), - systemImage: "plus" - ) { - appStore.showAddDevice() - } - ToolbarIconButton( - title: L10n.string("toolbar.diagnostics"), - systemImage: "wrench.and.screwdriver" - ) { - diagnosticsPresented = true - } - ToolbarIconButton( - title: L10n.string("toolbar.forget"), - systemImage: "trash", - disabled: appStore.selectedProfile == nil || appStore.backend.isRunning - ) { - guard let profile = appStore.selectedProfile else { - return - } - profilePendingDeletion = profile - } - ToolbarIconButton( - title: L10n.string("toolbar.cancel"), - systemImage: "xmark.circle", - disabled: !appStore.backend.canCancel - ) { - appStore.operationCoordinator.cancel() - } - } - } - } - .frame(minWidth: 1080, minHeight: 720) - .task { - await appStore.start() - } - .onChange(of: addDeviceStore.savedProfile) { profile in - guard let profile else { return } - appStore.select(profile) - } - .sheet(isPresented: $diagnosticsPresented) { - AppDiagnosticsView( - store: appStore.appReadinessStore, - events: appStore.backend.events, - helperPath: Binding( - get: { appStore.backend.helperPath }, - set: { appStore.backend.helperPath = $0 } - ) - ) - } - .confirmationDialog( - L10n.string("dialog.forget.title"), - isPresented: deleteConfirmationPresented, - presenting: profilePendingDeletion - ) { profile in - Button(L10n.format("dialog.forget.action", profile.title), role: .destructive) { - Task { @MainActor in - do { - try await appStore.forget(profile) - profilePendingDeletion = nil - } catch { - deleteErrorMessage = error.localizedDescription - } - } - } - Button(L10n.string("action.cancel"), role: .cancel) { - profilePendingDeletion = nil - } - } message: { profile in - Text(L10n.format("dialog.forget.message", profile.title)) - } - .alert(L10n.string("dialog.forget.error_title"), isPresented: deleteErrorPresented) { - Button(L10n.string("action.ok"), role: .cancel) { - deleteErrorMessage = nil - } - } message: { - Text(deleteErrorMessage ?? "") - } - .alert( - appStore.backend.pendingConfirmation?.title ?? "", - isPresented: confirmationPresented, - presenting: appStore.backend.pendingConfirmation - ) { confirmation in - Button(confirmation.actionTitle, role: .destructive) { - appStore.backend.confirmPending() - } - Button(L10n.string("action.cancel"), role: .cancel) { - appStore.backend.pendingConfirmation = nil - } - } message: { confirmation in - Text(confirmation.message) - } - } - - private var deleteConfirmationPresented: Binding { - Binding( - get: { profilePendingDeletion != nil }, - set: { isPresented in - if !isPresented { - profilePendingDeletion = nil - } - } - ) - } - - private var deleteErrorPresented: Binding { - Binding( - get: { deleteErrorMessage != nil }, - set: { isPresented in - if !isPresented { - deleteErrorMessage = nil - } - } - ) - } - - private var confirmationPresented: Binding { - Binding( - get: { appStore.backend.pendingConfirmation != nil }, - set: { isPresented in - if !isPresented { - appStore.backend.pendingConfirmation = nil - } - } - ) - } - - private var sidebarSelection: Binding { - Binding( - get: { - if appStore.showingAddDevice { - return "add" - } - if let selectedDeviceID = appStore.selectedDeviceID { - return "device:\(selectedDeviceID)" - } - return "all" - }, - set: { value in - guard let value else { return } - if value == "add" { - appStore.showAddDevice() - } else if value == "all" { - appStore.selectedDeviceID = nil - appStore.showingAddDevice = false - } else if value.hasPrefix("device:") { - let id = String(value.dropFirst("device:".count)) - if let profile = appStore.deviceRegistry.profile(id: id) { - appStore.select(profile) - } - } - } - ) - } - - private var sidebar: some View { - List(selection: sidebarSelection) { - Label(L10n.string("sidebar.all_time_capsules"), systemImage: "externaldrive.connected.to.line.below") - .tag("all") - - Section(L10n.string("sidebar.devices")) { - ForEach(appStore.deviceRegistry.profiles) { profile in - DeviceSidebarRow( - profile: profile, - summary: appStore.dashboardSummary(for: profile) - ) - .tag("device:\(profile.id)") - } - } - - Section { - Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") - .tag("add") - } - } - .navigationTitle("TimeCapsuleSMB") - .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 360) - } - - @ViewBuilder - private var detail: some View { - if appStore.showingAddDevice { - AddDeviceView(store: addDeviceStore) - } else if let profile = appStore.selectedProfile { - DeviceDashboardView( - profile: profile, - session: dashboardStore.session(for: profile), - appStore: appStore, - showDiagnostics: { - diagnosticsPresented = true - } - ) - } else { - DeviceListOverviewView(appStore: appStore) - } - } -} - -private struct ToolbarIconButton: View { - let title: String - let systemImage: String - var disabled = false - let action: () -> Void - - @State private var isHovered = false - - var body: some View { - Button { - guard !disabled else { - return - } - action() - } label: { - Image(systemName: systemImage) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(disabled ? Color.secondary.opacity(0.5) : Color.primary) - .frame(width: 28, height: 28) - .background { - Circle() - .fill(isHovered && !disabled ? Color.primary.opacity(0.10) : Color.clear) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(title) - .accessibilityLabel(title) - .accessibilityValue(disabled ? L10n.string("toolbar.disabled") : "") - .onHover { hovering in - isHovered = hovering - } - } -} - -private struct DeviceListOverviewView: View { - @ObservedObject var appStore: AppStore - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(appStore.deviceRegistry.profiles.isEmpty ? L10n.string("overview.empty.title") : L10n.string("sidebar.all_time_capsules")) - .font(.title2.weight(.semibold)) - if appStore.deviceRegistry.profiles.isEmpty { - Text(L10n.string("overview.empty.message")) - .foregroundStyle(.secondary) - Button { - appStore.showAddDevice() - } label: { - Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") - } - } else { - ForEach(appStore.deviceRegistry.profiles) { profile in - let summary = appStore.dashboardSummary(for: profile) - Button { - appStore.select(profile) - } label: { - HStack { - VStack(alignment: .leading) { - Text(profile.title) - .font(.body.weight(.medium)) - Text(profile.host) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Label(summary.displayStatus.title, systemImage: summary.displayStatus.systemImage) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .buttonStyle(.plain) - Divider() - } - } - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } -} - -private struct AddDeviceView: View { - @ObservedObject var store: AddDeviceFlowStore - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - topSection - if store.entryMode == .manual { - connectionControls - Spacer(minLength: 0) - } else { - deviceResultsSection - connectionControls - } - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private var topSection: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .firstTextBaseline) { - Text(L10n.string("add_device.title")) - .font(.title2.weight(.semibold)) - Spacer() - Picker(L10n.string("add_device.connection_method"), selection: Binding( - get: { store.entryMode }, - set: { store.setEntryMode($0) } - )) { - ForEach(AddDeviceEntryMode.allCases) { mode in - Text(mode.title).tag(mode) - } - } - .pickerStyle(.segmented) - .frame(width: 360) - } - - HStack { - if store.entryMode == .discover { - Text(store.currentStage?.description ?? L10n.string("add_device.discover.placeholder")) - .foregroundStyle(.secondary) - Button { - store.runDiscover() - } label: { - Label(L10n.string("button.discover"), systemImage: "network") - } - .disabled(store.isRunning || store.bonjourTimeoutValue == nil) - } - Label(store.state.title, systemImage: statusIcon) - .foregroundStyle(statusColor) - } - .frame(minHeight: 28, alignment: .center) - - } - } - - private var deviceResultsSection: some View { - Group { - if store.entryMode == .discover && !store.devices.isEmpty { - VStack(alignment: .leading, spacing: 6) { - Text(L10n.string("add_device.discovered_devices")) - .font(.headline) - - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(store.devices) { device in - Button { - store.select(device) - } label: { - DeviceCandidateRow(device: device, selected: store.selectedDeviceID == device.id) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - } - } - } - .scrollIndicators(.visible) - .frame(maxWidth: .infinity) - } - } else { - Spacer(minLength: 24) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private var connectionControls: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - TextField(L10n.string("add_device.host_or_ip"), text: Binding( - get: { store.hostFieldText }, - set: { store.manualHost = $0 } - )) - .disabled(!store.isHostFieldEditable) - SecureField(L10n.string("add_device.password"), text: $store.password) - .onSubmit { - guard store.canConfigure else { - return - } - store.runConfigure() - } - } - - HStack { - Button { - store.runConfigure() - } label: { - Label(L10n.string("add_device.save_device"), systemImage: "checkmark.circle") - } - .disabled(!store.canConfigure) - - Button { - store.reset() - } label: { - Label(L10n.string("add_device.reset"), systemImage: "arrow.counterclockwise") - } - .disabled(store.isRunning) - } - - if let profile = store.savedProfile { - Label(L10n.format("add_device.saved", profile.title), systemImage: "checkmark.circle") - .foregroundStyle(.green) - } - - if let error = store.error { - ErrorBlock(error: error) - } - } - } - - private var statusIcon: String { - switch store.state { - case .idle, .manualEntry, .passwordEntry: - return "circle" - case .discovering, .configuring, .savingProfile: - return "hourglass" - case .discoveryReady, .saved: - return "checkmark.circle" - case .discoveryEmpty: - return "magnifyingglass" - case .authFailed, .unsupported, .failed: - return "exclamationmark.triangle" - } - } - - private var statusColor: Color { - switch store.state { - case .discoveryReady, .saved: - return .green - case .authFailed, .unsupported, .failed: - return .red - default: - return .secondary - } - } -} - -private struct DeviceCandidateRow: View { - let device: DiscoveredDevice - let selected: Bool - - var body: some View { - HStack { - Image(systemName: selected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(selected ? Color.accentColor : Color.secondary) - VStack(alignment: .leading) { - Text(device.name) - Text([device.host, device.hostname].filter { !$0.isEmpty }.joined(separator: " ")) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if !device.discoveryModelText.isEmpty { - Text(device.discoveryModelText) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .padding(.vertical, 6) - } -} - -private struct DeviceDashboardView: View { - let profile: DeviceProfile - @ObservedObject var session: DeviceDashboardSession - @ObservedObject var appStore: AppStore - let showDiagnostics: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - Picker("", selection: $session.selectedTab) { - ForEach(DeviceDashboardTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .padding() - - Divider() - - ScrollView { - Group { - switch session.selectedTab { - case .overview: - OverviewTab(profile: profile, session: session, appStore: appStore) - case .install: - InstallTab(profile: profile, session: session, showDiagnostics: showDiagnostics) - case .checkup: - CheckupTab(profile: profile, session: session, showDiagnostics: showDiagnostics) - case .maintenance: - MaintenanceTab(profile: profile, session: session, showDiagnostics: showDiagnostics) - case .advanced: - AdvancedTab(profile: profile, session: session, appStore: appStore) - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } -} - -private struct OverviewTab: View { - let profile: DeviceProfile - @ObservedObject var session: DeviceDashboardSession - @ObservedObject var appStore: AppStore - - var body: some View { - let summary = session.summary(for: profile) - VStack(alignment: .leading, spacing: 16) { - if let warning = summary.hostWarning { - WarningBanner(warning: warning) - } - - Text(profile.title) - .font(.title2.weight(.semibold)) - - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { - GridRow { Text(L10n.string("dashboard.overview.status")).foregroundStyle(.secondary); Text(summary.displayStatus.title) } - GridRow { Text(L10n.string("dashboard.overview.host")).foregroundStyle(.secondary); Text(profile.host) } - GridRow { Text(L10n.string("dashboard.overview.model")).foregroundStyle(.secondary); Text(profile.model ?? L10n.string("value.unknown")) } - GridRow { Text(L10n.string("dashboard.overview.generation")).foregroundStyle(.secondary); Text(profile.deviceGeneration ?? L10n.string("value.unknown")) } - GridRow { Text(L10n.string("dashboard.overview.payload")).foregroundStyle(.secondary); Text(profile.payloadFamily ?? L10n.string("value.unknown")) } - GridRow { Text(L10n.string("dashboard.overview.password")).foregroundStyle(.secondary); Text(summary.passwordState.title) } - GridRow { Text(L10n.string("dashboard.overview.last_checkup")).foregroundStyle(.secondary); Text(profile.lastCheckup?.summary ?? L10n.string("value.never")) } - GridRow { Text(L10n.string("dashboard.overview.last_install")).foregroundStyle(.secondary); Text(profile.lastDeploy?.summary ?? L10n.string("value.never")) } - } - - HStack { - Button(primaryActionTitle(summary.primaryAction)) { - runPrimary(summary.primaryAction) - } - .buttonStyle(.borderedProminent) - - Button { - session.runCheckup(profile: profile) - } label: { - Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") - } - } - - HStack { - SecureField(L10n.string("dashboard.replacement_password"), text: $session.replacementPassword) - Button { - Task { @MainActor in - try? await appStore.savePassword(session.replacementPassword, for: profile) - session.replacementPassword = "" - } - } label: { - Label(L10n.string("dashboard.action.save_password"), systemImage: "key") - } - .disabled(session.replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - - if let passwordError = session.passwordError { - Text(passwordError) - .foregroundStyle(.red) - } - } - } - - private func primaryActionTitle(_ action: DashboardPrimaryAction) -> String { - switch action { - case .addDevice: - return L10n.string("sidebar.add_time_capsule") - case .replacePassword: - return L10n.string("dashboard.action.replace_password") - case .runCheckup: - return L10n.string("dashboard.action.run_checkup") - case .installSMB: - return L10n.string("dashboard.action.install_smb") - case .viewCheckup: - return L10n.string("dashboard.action.view_checkup") - case .openSMB: - return L10n.string("dashboard.action.open_smb") - } - } - - private func runPrimary(_ action: DashboardPrimaryAction) { - switch action { - case .replacePassword: - session.replacementPassword = "" - case .runCheckup: - session.runCheckup(profile: profile) - case .viewCheckup: - session.selectedTab = .checkup - case .openSMB: - openSMBAddress() - case .installSMB: - session.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 session: DeviceDashboardSession - let showDiagnostics: () -> Void - - var body: some View { - let store = session.deployStore - VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("dashboard.tab.install")) - .font(.title2.weight(.semibold)) - HStack { - Toggle(L10n.string("toggle.enable_nbns"), isOn: $session.deployStore.nbnsEnabled) - Toggle(L10n.string("toggle.no_reboot"), isOn: $session.deployStore.noReboot) - Toggle(L10n.string("toggle.no_wait"), isOn: $session.deployStore.noWait) - Toggle(L10n.string("toggle.force_debug_logging"), isOn: $session.deployStore.debugLogging) - TextField(L10n.string("field.mount_wait"), text: $session.deployStore.mountWait) - .frame(width: 150) - } - HStack { - Button { - session.runInstallPlan(profile: profile) - } label: { - Label(L10n.string("deploy.action.plan_install"), systemImage: "doc.text.magnifyingglass") - } - .disabled(store.isRunning || store.mountWaitValue == nil) - Button { - session.runInstall(profile: profile) - } label: { - Label(L10n.string("dashboard.action.install_smb"), systemImage: "square.and.arrow.up") - } - .disabled(!store.canDeploy) - Label(store.state.title, systemImage: "circle") - } - if let stage = store.currentStage { - StageLine(stage: stage) - } - if let plan = store.plan { - let presentation = DeployPlanPresentation( - plan: plan, - profile: profile, - hostWarning: HostCompatibilityPolicy.warning() - ) - Text(presentation.title) - .font(.headline) - SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) - ForEach(presentation.warnings, id: \.self) { warning in - Label(warning, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.yellow) - } - DisclosureGroup(L10n.string("deploy.advanced_plan_details")) { - SummaryGrid(rows: presentation.advancedRows.map { ($0.label, $0.value) }) - .padding(.top, 6) - } - } - if let result = store.result { - SummaryGrid(rows: [ - (L10n.string("deploy.result.verified"), result.verified == true ? L10n.string("value.yes") : L10n.string("value.no")), - (L10n.string("deploy.result.reboot_requested"), result.rebootRequested == true ? L10n.string("value.yes") : L10n.string("value.no")), - (L10n.string("deploy.result.message"), result.message ?? L10n.string("deploy.result.default_message")) - ]) - } - if let error = store.error { - ErrorRecoveryView(error: error) { action in - handleRecovery(action: action, error: error) - } - } - } - } - - private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { - if action.kind == .diagnostics { - showDiagnostics() - return - } - _ = session.handleRecoveryAction(action, error: error, profile: profile) - } -} - -private struct CheckupTab: View { - let profile: DeviceProfile - @ObservedObject var session: DeviceDashboardSession - let showDiagnostics: () -> Void - - var body: some View { - let store = session.doctorStore - VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("dashboard.tab.checkup")) - .font(.title2.weight(.semibold)) - HStack { - TextField(L10n.string("field.bonjour_timeout"), text: $session.doctorStore.bonjourTimeout) - .frame(width: 180) - Button { - session.runCheckup(profile: profile) - } label: { - Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") - } - .disabled(store.isRunning || store.bonjourTimeoutValue == nil) - Label(store.state.title, systemImage: "circle") - } - if let stage = store.currentStage { - StageLine(stage: stage) - } - if let summary = store.summary { - let presentation = CheckupPresentation(summary: summary, state: store.state) - Text(presentation.headline) - .font(.headline) - SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) - ForEach(presentation.groups) { group in - VStack(alignment: .leading, spacing: 4) { - Text(group.domain).font(.headline) - ForEach(Array(group.checks.enumerated()), id: \.offset) { _, check in - HStack { - Text(check.status) - .font(.system(.caption, design: .monospaced)) - .frame(width: 44, alignment: .leading) - Text(check.message) - .font(.caption) - } - } - } - } - } - if let error = store.error { - ErrorRecoveryView(error: error) { action in - handleRecovery(action: action, error: error) - } - } - } - } - - private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { - if action.kind == .diagnostics { - showDiagnostics() - return - } - _ = session.handleRecoveryAction(action, error: error, profile: profile) - } -} - -private struct MaintenanceTab: View { - let profile: DeviceProfile - @ObservedObject var session: DeviceDashboardSession - let showDiagnostics: () -> Void - - var body: some View { - let store = session.maintenanceStore - let presentation = MaintenanceWorkflowPresentation.presentation(for: store.selectedWorkflow) - VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("dashboard.tab.maintenance")) - .font(.title2.weight(.semibold)) - Picker(L10n.string("dashboard.tab.maintenance"), selection: $session.maintenanceStore.selectedWorkflow) { - Text(L10n.string("maintenance.workflow.activate")).tag(MaintenanceWorkflow.activate) - Text(L10n.string("maintenance.workflow.uninstall")).tag(MaintenanceWorkflow.uninstall) - Text(L10n.string("maintenance.workflow.fsck")).tag(MaintenanceWorkflow.fsck) - Text(L10n.string("maintenance.workflow.repair_xattrs")).tag(MaintenanceWorkflow.repairXattrs) - } - .pickerStyle(.segmented) - - VStack(alignment: .leading, spacing: 4) { - Text(presentation.title) - .font(.headline) - Text(presentation.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - Label(presentation.risk, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack { - TextField(L10n.string("field.mount_wait"), text: $session.maintenanceStore.mountWait) - .frame(width: 150) - Toggle(L10n.string("toggle.no_reboot"), isOn: $session.maintenanceStore.noReboot) - Toggle(L10n.string("toggle.no_wait"), isOn: $session.maintenanceStore.noWait) - } - - maintenanceControls(store: store) - FlashBootHookSection(profile: profile) - - if let stage = store.currentStage { - StageLine(stage: stage) - } - if let error = store.error { - ErrorRecoveryView(error: error) { action in - handleRecovery(action: action, error: error) - } - } - } - } - - private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { - if action.kind == .diagnostics { - showDiagnostics() - return - } - _ = session.handleRecoveryAction(action, error: error, profile: profile) - } - - @ViewBuilder - private func maintenanceControls(store: MaintenanceStore) -> some View { - switch store.selectedWorkflow { - case .activate: - HStack { - Button(L10n.string("maintenance.action.plan_start_smb")) { - if let password = session.maintenancePassword(for: profile) { - store.planActivation(password: password, profile: profile) - } - } - Button(L10n.string("maintenance.action.start_smb")) { - if let password = session.maintenancePassword(for: profile) { - store.runActivation(password: password, profile: profile) - } - } - .disabled(!store.canRunActivation) - Label(store.activateState.title, systemImage: "circle") - } - case .uninstall: - HStack { - Button(L10n.string("maintenance.action.plan_uninstall")) { - if let password = session.maintenancePassword(for: profile) { - store.planUninstall(password: password, profile: profile) - } - } - Button(L10n.string("maintenance.action.uninstall")) { - if let password = session.maintenancePassword(for: profile) { - store.runUninstall(password: password, profile: profile) - } - } - .disabled(!store.canRunUninstall) - Label(store.uninstallState.title, systemImage: "circle") - } - case .fsck: - VStack(alignment: .leading, spacing: 8) { - HStack { - Button(L10n.string("maintenance.action.find_volumes")) { - if let password = session.maintenancePassword(for: profile) { - store.refreshFsckTargets(password: password, profile: profile) - } - } - Button(L10n.string("maintenance.action.plan_disk_repair")) { - if let password = session.maintenancePassword(for: profile) { - store.planFsck(password: password, profile: profile) - } - } - .disabled(!store.canPlanFsck) - Button(L10n.string("maintenance.action.run_disk_repair")) { - if let password = session.maintenancePassword(for: profile) { - store.runFsck(password: password, profile: profile) - } - } - .disabled(!store.canRunFsck) - Label(store.fsckState.title, systemImage: "circle") - } - ForEach(store.fsckTargets) { target in - Button { - store.selectedFsckTargetID = target.id - } label: { - HStack { - Image(systemName: store.selectedFsckTargetID == target.id ? "checkmark.circle.fill" : "circle") - Text(target.name ?? target.device) - Text(target.mountpoint).foregroundStyle(.secondary) - } - } - .buttonStyle(.plain) - } - } - case .repairXattrs: - VStack(alignment: .leading, spacing: 8) { - HStack { - TextField(L10n.string("field.repair_xattrs_path"), text: $session.maintenanceStore.repairPath) - Button { - chooseRepairPath(store: store) - } label: { - Label(L10n.string("maintenance.action.choose_folder"), systemImage: "folder") - } - } - HStack { - Button(L10n.string("maintenance.action.scan_metadata")) { - store.scanRepairXattrs() - } - Button(L10n.string("maintenance.action.repair_metadata")) { - store.runRepairXattrs() - } - .disabled(!store.canRepairXattrs) - Label(store.repairState.title, systemImage: "circle") - } - if let scan = store.repairScan { - Text(L10n.format("maintenance.repairable_count", scan.repairableCount)) - .foregroundStyle(.secondary) - } - } - } - } - - private func chooseRepairPath(store: MaintenanceStore) { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.prompt = L10n.string("maintenance.action.choose") - if panel.runModal() == .OK, let url = panel.url { - store.repairPath = url.path - } - } -} - -private struct AdvancedTab: View { - let profile: DeviceProfile - @ObservedObject var session: DeviceDashboardSession - @ObservedObject var appStore: AppStore - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("dashboard.tab.advanced")) - .font(.title2.weight(.semibold)) - DeviceProfileEditorView(profile: profile, store: session.profileEditorStore) - SummaryGrid(rows: [ - (L10n.string("advanced.profile_id"), profile.id), - (L10n.string("advanced.config"), profile.configPath), - (L10n.string("advanced.helper"), appStore.backend.helperPath.isEmpty ? L10n.string("value.auto") : appStore.backend.helperPath) - ]) - EventList(events: appStore.backend.events) - } - } -} - -private struct DeviceProfileEditorView: View { - let profile: DeviceProfile - @ObservedObject var store: DeviceProfileEditorStore - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(L10n.string("profile_editor.title")) - .font(.headline) - - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Text(L10n.string("profile_editor.display_name")) - .foregroundStyle(.secondary) - TextField(L10n.string("profile_editor.display_name"), text: $store.draft.displayName) - .frame(maxWidth: 360) - } - GridRow { - Text(L10n.string("dashboard.overview.host")) - .foregroundStyle(.secondary) - TextField(L10n.string("dashboard.overview.host"), text: $store.draft.host) - .frame(maxWidth: 360) - } - GridRow { - Text(L10n.string("field.mount_wait")) - .foregroundStyle(.secondary) - TextField(L10n.string("field.mount_wait"), text: $store.draft.mountWaitSeconds) - .frame(width: 160) - } - } - - HStack { - Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) - Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) - } - - HStack { - Button { - Task { @MainActor in - await store.save(profile: profile) - } - } label: { - Label(L10n.string("profile_editor.save"), systemImage: "square.and.arrow.down") - } - .disabled(!store.canSave(profile: profile)) - - Button { - store.reset(to: profile) - } label: { - Label(L10n.string("profile_editor.reset"), systemImage: "arrow.counterclockwise") - } - .disabled(store.isRunning) - - Label(store.state.title, systemImage: "circle") - .foregroundStyle(.secondary) - } - - ForEach(store.validationErrors, id: \.self) { validationError in - Text(validationError.localizedDescription) - .font(.caption) - .foregroundStyle(.red) - } - - if let stage = store.currentStage { - StageLine(stage: stage) - } - if let error = store.error { - ErrorRecoveryView(error: error) { _ in } - } - } - .padding(.bottom, 8) - } -} - -private struct AppReadinessBannerView: View { - @ObservedObject var store: AppReadinessStore - let showDiagnostics: () -> Void - - var body: some View { - switch store.state { - case .idle, .ready: - EmptyView() - case .resolvingBundle, .checkingCapabilities, .validatingInstall: - HStack(spacing: 10) { - ProgressView() - .controlSize(.small) - Text(title) - .font(.caption) - if let stage = store.currentStage?.description ?? store.currentStage?.stage { - Text(stage) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - .padding(.horizontal) - .padding(.vertical, 8) - .background(Color.secondary.opacity(0.08)) - case .degraded(_, let issues): - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.yellow) - Text(issues.first?.message ?? L10n.string("readiness.warning.default")) - .font(.caption) - Spacer() - Button(L10n.string("toolbar.diagnostics"), action: showDiagnostics) - } - .padding(.horizontal) - .padding(.vertical, 8) - .background(Color.yellow.opacity(0.12)) - case .blocked: - EmptyView() - } - } - - private var title: String { - switch store.state.kind { - case .resolvingBundle: - return L10n.string("readiness.state.resolving_bundle") - case .checkingCapabilities: - return L10n.string("readiness.state.checking_capabilities") - case .validatingInstall: - return L10n.string("readiness.state.validating_install") - default: - return "" - } - } -} - -private struct AppReadinessBlockedView: View { - @ObservedObject var store: AppReadinessStore - let showDiagnostics: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Label(L10n.string("readiness.blocked.title"), systemImage: "exclamationmark.octagon") - .font(.title2.weight(.semibold)) - .foregroundStyle(.red) - if case .blocked(let issue) = store.state { - Text(issue.message) - Text(issue.recovery) - .foregroundStyle(.secondary) - } - HStack { - Button { - store.start() - } label: { - Label(L10n.string("recovery.action.retry"), systemImage: "arrow.clockwise") - } - .disabled(!store.canRetry) - - Button { - showDiagnostics() - } label: { - Label(L10n.string("toolbar.diagnostics"), systemImage: "wrench.and.screwdriver") - } - } - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } -} - -private struct AppDiagnosticsView: View { - @ObservedObject var store: AppReadinessStore - let events: [BackendEvent] - @Binding var helperPath: String - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text(L10n.string("diagnostics.title")) - .font(.title2.weight(.semibold)) - Spacer() - Button(L10n.string("action.done")) { - dismiss() - } - .keyboardShortcut(.defaultAction) - } - - TextField(L10n.string("field.helper"), text: $helperPath) - - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { - GridRow { - Text(L10n.string("diagnostics.state")).foregroundStyle(.secondary) - Text(store.state.kind.title) - } - if let capabilities = store.capabilities { - GridRow { - Text(L10n.string("diagnostics.helper")).foregroundStyle(.secondary) - Text(capabilities.helperVersion) - } - GridRow { - Text(L10n.string("diagnostics.distribution")).foregroundStyle(.secondary) - Text(capabilities.distributionRoot) - .lineLimit(1) - .truncationMode(.middle) - } - } - if let validation = store.validation { - GridRow { - Text(L10n.string("diagnostics.validation")).foregroundStyle(.secondary) - Text(validation.summary) - } - } - } - .font(.caption) - - if !store.issues.isEmpty { - VStack(alignment: .leading, spacing: 6) { - Text(L10n.string("diagnostics.runtime_issues")) - .font(.headline) - ForEach(store.issues) { issue in - VStack(alignment: .leading, spacing: 2) { - Text(issue.message) - Text(issue.recovery) - .foregroundStyle(.secondary) - } - .font(.caption) - } - } - } - - Text(L10n.string("diagnostics.backend_events")) - .font(.headline) - EventList(events: events) - } - .padding() - .frame(minWidth: 720, minHeight: 520) - } -} - -private struct EventList: View { - let events: [BackendEvent] - - var body: some View { - List(events) { event in - VStack(alignment: .leading, spacing: 4) { - Text(event.summary) - .font(.body) - if let payload = event.payload, event.type == "result" { - Text(payload.displayText) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(6) - } - } - .padding(.vertical, 3) - } - } -} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift deleted file mode 100644 index c625e06c..00000000 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift +++ /dev/null @@ -1,201 +0,0 @@ -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/DoctorView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift deleted file mode 100644 index b03568c8..00000000 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift +++ /dev/null @@ -1,150 +0,0 @@ -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/MaintenanceView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift deleted file mode 100644 index 93fb23ae..00000000 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift +++ /dev/null @@ -1,282 +0,0 @@ -import SwiftUI - -struct MaintenanceView: View { - @ObservedObject var store: MaintenanceStore - @Binding var password: String - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("screen.maintenance")) - .font(.title2.weight(.semibold)) - - Picker("Maintenance", selection: $store.selectedWorkflow) { - ForEach(MaintenanceWorkflow.allCases) { workflow in - Text(workflow.title).tag(workflow) - } - } - .pickerStyle(.segmented) - - sharedOptions - - switch store.selectedWorkflow { - case .activate: - activatePanel - case .uninstall: - uninstallPanel - case .fsck: - fsckPanel - case .repairXattrs: - repairPanel - } - - if let stage = store.currentStage { - StageLine(stage: stage) - } - - if let error = store.error { - MaintenanceErrorView(error: error) - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var sharedOptions: some View { - HStack { - TextField(L10n.string("field.mount_wait"), text: $store.mountWait) - .frame(width: 150) - Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) - Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) - } - } - - private var activatePanel: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Button { - store.planActivation(password: password) - } label: { - Label("Plan Activation", systemImage: "doc.text.magnifyingglass") - } - .disabled(store.isRunning) - - Button { - store.runActivation(password: password) - } label: { - Label(L10n.string("button.activate"), systemImage: "power") - } - .disabled(!store.canRunActivation) - - StatusLabel(state: store.activateState) - } - - if let plan = store.activationPlan { - Text("\(plan.actions.count) action(s), \(plan.postActivationChecks.count) post-check(s)") - .font(.caption) - .foregroundStyle(.secondary) - } - if let result = store.activationResult { - Text(result.summary) - .font(.caption) - if let message = result.message { - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - - private var uninstallPanel: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Button { - store.planUninstall(password: password) - } label: { - Label(L10n.string("button.uninstall_plan"), systemImage: "doc.text.magnifyingglass") - } - .disabled(store.isRunning || store.mountWaitValue == nil) - - Button { - store.runUninstall(password: password) - } label: { - Label(L10n.string("button.uninstall"), systemImage: "xmark.bin.fill") - } - .disabled(!store.canRunUninstall) - - StatusLabel(state: store.uninstallState) - } - - if let plan = store.uninstallPlan { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { - GridRow { - Text("Host").foregroundStyle(.secondary) - Text(plan.host) - } - GridRow { - Text("Reboot").foregroundStyle(.secondary) - Text(plan.requiresReboot ? "required" : "not required") - } - GridRow { - Text("Payload Dirs").foregroundStyle(.secondary) - Text(plan.payloadDirs.joined(separator: ", ")) - .lineLimit(1) - .truncationMode(.middle) - } - } - .font(.caption) - } - if let result = store.uninstallResult { - Text("\(result.summary) rebooted: \(yesNo(result.rebooted)), waited: \(yesNo(result.waited)), verified: \(yesNo(result.verified))") - .font(.caption) - } - } - } - - private var fsckPanel: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Button { - store.refreshFsckTargets(password: password) - } label: { - Label(L10n.string("button.list_fsck_volumes"), systemImage: "list.bullet.rectangle") - } - .disabled(store.isRunning || store.mountWaitValue == nil) - - Button { - store.planFsck(password: password) - } label: { - Label(L10n.string("button.plan_fsck"), systemImage: "doc.text.magnifyingglass") - } - .disabled(!store.canPlanFsck) - - Button { - store.runFsck(password: password) - } label: { - Label(L10n.string("button.run_fsck"), systemImage: "externaldrive.badge.checkmark") - } - .disabled(!store.canRunFsck) - - StatusLabel(state: store.fsckState) - } - - if !store.fsckTargets.isEmpty { - Picker("Volume", selection: $store.selectedFsckTargetID) { - Text("Select volume").tag(Optional.none) - ForEach(store.fsckTargets) { target in - Text("\(target.device) on \(target.mountpoint)").tag(Optional(target.id)) - } - } - .frame(maxWidth: 520) - } - if let plan = store.fsckPlan { - Text("Plan: \(plan.device) on \(plan.mountpoint), reboot: \(yesNo(plan.rebootRequired)), wait: \(yesNo(plan.waitAfterReboot))") - .font(.caption) - } - if let result = store.fsckResult { - Text("Result: \(result.device) return \(result.returncode.map(String.init) ?? "n/a"), waited: \(yesNo(result.waited)), verified: \(yesNo(result.verified))") - .font(.caption) - } - } - } - - private var repairPanel: some View { - VStack(alignment: .leading, spacing: 8) { - TextField(L10n.string("field.repair_xattrs_path"), text: $store.repairPath) - HStack { - Button { - store.scanRepairXattrs() - } label: { - Label(L10n.string("button.scan_xattrs"), systemImage: "wand.and.stars") - } - .disabled(store.isRunning || store.repairPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - - Button { - store.runRepairXattrs() - } label: { - Label(L10n.string("button.repair_xattrs"), systemImage: "wand.and.stars.inverse") - } - .disabled(!store.canRepairXattrs) - - StatusLabel(state: store.repairState) - } - - if let scan = store.repairScan { - Text("Scan: \(scan.findingCount) finding(s), \(scan.repairableCount) repairable.") - .font(.caption) - if let report = scan.report, !report.isEmpty { - Text(report) - .font(.system(.caption, design: .monospaced)) - .lineLimit(4) - .foregroundStyle(.secondary) - } - } - if let result = store.repairResult { - Text("Repair: \(result.summary)") - .font(.caption) - } - } - } - - private func yesNo(_ value: Bool?) -> String { - value == true ? "yes" : "no" - } -} - -private struct StatusLabel: View { - let state: MaintenanceOperationState - - var body: some View { - Label(state.title, systemImage: icon) - .foregroundStyle(color) - } - - private var icon: String { - switch state { - case .idle: - return "circle" - case .loading, .planning, .scanning, .running, .repairing: - return "hourglass" - case .listReady, .planReady, .scanReady, .succeeded, .repaired: - return "checkmark.circle" - case .planStale, .scanStale, .awaitingConfirmation: - return "exclamationmark.circle" - case .failed: - return "exclamationmark.triangle" - } - } - - private var color: Color { - switch state { - case .listReady, .planReady, .scanReady, .succeeded, .repaired: - return .green - case .planStale, .scanStale, .awaitingConfirmation: - return .orange - case .failed: - return .red - default: - return .secondary - } - } -} - -private struct MaintenanceErrorView: View { - let error: BackendErrorViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(error.recovery?.title ?? error.code) - .font(.body.weight(.medium)) - Text(error.message) - .font(.caption) - if let recovery = error.recovery, !recovery.actions.isEmpty { - ForEach(recovery.actions, id: \.self) { action in - Text(action) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .foregroundStyle(.red) - } -} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/HostCompatibilityPolicy.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/HostCompatibilityPolicy.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/RecoveryActionMapper.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/RecoveryActionMapper.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ValueParsers.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/ValueParsers.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ValueParsers.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/ValueParsers.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift new file mode 100644 index 00000000..9dacb811 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift @@ -0,0 +1,97 @@ +import Foundation + +struct DiscoveredDevice: Identifiable, Equatable { + let id: String + let name: String + let host: String + let hostname: String + let addresses: [String] + let syap: String? + let model: String? + let rawRecord: JSONValue + + init(payload: DiscoveredDevicePayload, index: Int) { + self.id = payload.id.isEmpty ? "discovered-\(index)" : payload.id + self.name = payload.name.isEmpty ? (payload.hostname.isEmpty ? "AirPort Device" : payload.hostname) : payload.name + self.host = payload.host + self.hostname = payload.hostname + self.addresses = payload.addresses.isEmpty ? payload.ipv4 + payload.ipv6 : payload.addresses + self.syap = Self.nonEmpty(payload.syap) + self.model = Self.nonEmpty(payload.model) ?? Self.recordProperty(payload.selectedRecord, keys: ["model", "am"]) + self.rawRecord = payload.selectedRecord + } + + init(record: BonjourResolvedServicePayload, index: Int) { + let stableParts = [ + record.fullname, + record.serviceType, + record.name, + record.hostname, + record.ipv4.joined(separator: ","), + record.ipv6.joined(separator: ",") + ] + let stableID = stableParts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "|") + + self.id = stableID.isEmpty ? "discovered-\(index)" : stableID + self.name = record.name.isEmpty ? (record.hostname.isEmpty ? "AirPort Device" : record.hostname) : record.name + self.hostname = record.hostname + self.addresses = record.ipv4 + record.ipv6 + self.host = Self.displayHost(record) + self.syap = Self.nonEmpty(record.properties["syAP"] ?? record.properties["syap"]) + self.model = Self.nonEmpty(record.properties["model"] ?? record.properties["am"]) + self.rawRecord = record.jsonValue + } + + var discoveryModelText: String { + Self.nonEmpty(model) ?? "" + } + + private static func displayHost(_ record: BonjourResolvedServicePayload) -> String { + if let address = record.ipv4.first ?? record.ipv6.first { + return address + } + return record.hostname + } + + private static func recordProperty(_ record: JSONValue, keys: [String]) -> String? { + guard case .object(let values) = record, case .object(let properties)? = values["properties"] else { + return nil + } + for key in keys { + if case .string(let value)? = properties[key], let trimmed = nonEmpty(value) { + return trimmed + } + } + return nil + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } +} + +struct ConfiguredDeviceState: Equatable { + let host: String + let configPath: String + let configureId: String + let sshAuthenticated: Bool + let syap: String? + let model: String? + let compatibility: DeviceCompatibilityPayload? + + init(payload: ConfigurePayload) { + self.host = payload.host + self.configPath = payload.configPath + self.configureId = payload.configureId + self.sshAuthenticated = payload.sshAuthenticated + self.syap = payload.deviceSyap ?? payload.device?.syap + self.model = payload.deviceModel ?? payload.device?.model + self.compatibility = payload.compatibility + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceProfileSaver.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceProfileSaver.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditableFields.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditableFields.swift new file mode 100644 index 00000000..fe89caba --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditableFields.swift @@ -0,0 +1,6 @@ +import Foundation + +struct DeviceProfileEditableFields: Equatable { + let displayName: String + let settings: DeviceProfileSettings +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileEditorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift similarity index 98% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileEditorStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift index 264d6330..13e613b0 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileEditorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift @@ -105,6 +105,10 @@ struct DeviceProfileEditorDraft: Equatable { mountWaitSeconds: mountWait ) } + + func editableFields() throws -> DeviceProfileEditableFields { + DeviceProfileEditableFields(displayName: displayName, settings: try validatedSettings()) + } } @MainActor @@ -214,7 +218,7 @@ final class DeviceProfileEditorStore: ObservableObject { error = nil currentStage = nil do { - let saved = try await appStore.saveProfileEdits(profile: profile, draft: draft) + let saved = try await appStore.saveProfileEdits(profile: profile, fields: draft.editableFields()) savedProfile = saved applyDraft(DeviceProfileEditorDraft(profile: saved)) state = .saved diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileTraits.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileTraits.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileTraits.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileTraits.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/PasswordStore.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/PasswordStore.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift deleted file mode 100644 index 41a1f755..00000000 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift +++ /dev/null @@ -1,216 +0,0 @@ -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 deleted file mode 100644 index b93680f8..00000000 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessView.swift +++ /dev/null @@ -1,198 +0,0 @@ -import SwiftUI - -struct ReadinessView: View { - @ObservedObject var store: ReadinessStore - @Binding var helperPath: String - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("screen.readiness")) - .font(.title2.weight(.semibold)) - - TextField(L10n.string("field.helper"), text: $helperPath) - - HStack { - readinessButton( - L10n.string("button.capabilities"), - icon: "info.circle", - state: store.capabilitiesState, - action: store.runCapabilities - ) - readinessButton( - L10n.string("button.paths"), - icon: "folder", - state: store.pathsState, - action: store.runPaths - ) - readinessButton( - L10n.string("button.validate"), - icon: "checkmark.seal", - state: store.validationState, - action: store.runValidateInstall - ) - } - - if let stage = store.currentStage { - HStack(spacing: 8) { - Text(stage.stage) - .font(.system(.caption, design: .monospaced)) - if let description = stage.description { - Text(description) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - if let capabilities = store.capabilities { - CapabilitiesSummaryView(payload: capabilities) - } - - if let paths = store.paths { - PathsSummaryView(payload: paths) - } - - if let validation = store.validation { - ValidationSummaryView(payload: validation) - } - - if let error = store.error { - ReadinessErrorView(error: error) - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func readinessButton( - _ title: String, - icon: String, - state: ReadinessOperationState, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Label("\(title) (\(state.title))", systemImage: icon) - } - .disabled(store.isRunning) - } -} - -private struct CapabilitiesSummaryView: View { - let payload: CapabilitiesPayload - - var body: some View { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { - GridRow { - Text("Helper").foregroundStyle(.secondary) - Text("\(payload.helperVersion) (\(payload.helperVersionCode))") - } - GridRow { - Text("API Schema").foregroundStyle(.secondary) - Text(String(payload.apiSchemaVersion)) - } - GridRow { - Text("Confirmations").foregroundStyle(.secondary) - Text(String(payload.confirmationSchemaVersion)) - } - GridRow { - Text("Operations").foregroundStyle(.secondary) - Text(payload.operations.joined(separator: ", ")) - .lineLimit(2) - } - } - .font(.caption) - } -} - -private struct PathsSummaryView: View { - let payload: PathsPayload - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { - GridRow { - Text("Distribution").foregroundStyle(.secondary) - Text(payload.distributionRoot).lineLimit(1).truncationMode(.middle) - } - GridRow { - Text("Config").foregroundStyle(.secondary) - Text(payload.configPath).lineLimit(1).truncationMode(.middle) - } - GridRow { - Text("State").foregroundStyle(.secondary) - Text(payload.stateDir).lineLimit(1).truncationMode(.middle) - } - } - if !payload.artifacts.isEmpty { - Text("Artifacts") - .font(.body.weight(.medium)) - ForEach(payload.artifacts, id: \.name) { artifact in - HStack { - Image(systemName: artifact.ok ? "checkmark.circle" : "xmark.circle") - .foregroundStyle(artifact.ok ? .green : .red) - Text(artifact.name) - Text(artifact.repoRelativePath) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Text(artifact.message) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .font(.caption) - } - } - } - .font(.caption) - } -} - -private struct ValidationSummaryView: View { - let payload: InstallValidationPayload - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Image(systemName: payload.ok ? "checkmark.seal" : "xmark.seal") - .foregroundStyle(payload.ok ? .green : .red) - Text(payload.summary) - Text("\(payload.counts["pass"] ?? 0) passed, \(payload.counts["fail"] ?? 0) failed") - .foregroundStyle(.secondary) - } - ForEach(payload.checks, id: \.id) { check in - HStack { - Image(systemName: check.ok ? "checkmark.circle" : "xmark.circle") - .foregroundStyle(check.ok ? .green : .red) - Text(check.id) - Text(check.message) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .font(.caption) - } - } - .font(.caption) - } -} - -private struct ReadinessErrorView: View { - let error: BackendErrorViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(error.recovery?.title ?? error.code) - .font(.body.weight(.medium)) - Text(error.message) - .font(.caption) - if let recovery = error.recovery, !recovery.actions.isEmpty { - ForEach(recovery.actions, id: \.self) { action in - Text(action) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .foregroundStyle(.red) - } -} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 2e3d8994..46fb5ed2 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -102,11 +102,36 @@ "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.install_update_smb" = "Install / Update SMB"; +"dashboard.action.advanced" = "Advanced"; +"dashboard.action.open_finder" = "Open Finder"; "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.start_smb" = "Start SMB"; "dashboard.action.view_checkup" = "View Checkup"; +"dashboard.header.last_checked" = "Last checked"; +"dashboard.health.check_counts" = "PASS %d, WARN %d, FAIL %d"; +"dashboard.health.connection" = "Connection"; +"dashboard.health.connection.keychain_unavailable" = "The saved password cannot be read from Keychain."; +"dashboard.health.connection.password_available" = "Saved password is available for backend operations."; +"dashboard.health.connection.password_invalid" = "The saved password was rejected by the Time Capsule."; +"dashboard.health.connection.password_missing" = "Save the Time Capsule password before running checkups or installs."; +"dashboard.health.connection.running" = "A backend operation is using this profile."; +"dashboard.health.finder_bonjour" = "Finder / Bonjour"; +"dashboard.health.runtime" = "Runtime"; +"dashboard.health.runtime.activation_needed" = "This NetBSD4 device may need Start SMB after reboot."; +"dashboard.health.runtime.installing" = "Install / Update is running."; +"dashboard.health.runtime.not_installed" = "SMB has not been installed from this app."; +"dashboard.health.smb_auth" = "SMB Auth"; +"dashboard.health.status.failed" = "Failed"; +"dashboard.health.status.good" = "Good"; +"dashboard.health.status.running" = "Running"; +"dashboard.health.status.unknown" = "Unknown"; +"dashboard.health.status.warning" = "Warning"; +"dashboard.health.time_machine" = "Time Machine"; +"dashboard.health.unchecked" = "Run Checkup to inspect this area."; "dashboard.overview.generation" = "Generation"; "dashboard.overview.host" = "Host"; "dashboard.overview.last_checkup" = "Last Checkup"; @@ -115,6 +140,7 @@ "dashboard.overview.password" = "Password"; "dashboard.overview.payload" = "Payload"; "dashboard.overview.status" = "Status"; +"dashboard.password.title" = "Saved Password"; "dashboard.replacement_password" = "Update saved password"; "dashboard.tab.advanced" = "Advanced"; "dashboard.tab.checkup" = "Checkup"; @@ -142,6 +168,38 @@ "deploy.result.message" = "Message"; "deploy.result.reboot_requested" = "Reboot Requested"; "deploy.result.verified" = "Verified"; +"install.action.create_plan" = "Create Install Plan"; +"install.action.install_update" = "Install / Update"; +"install.action.regenerate_plan" = "Regenerate Plan"; +"install.advanced_options" = "Advanced Options"; +"install.completion.title.finished" = "Install / Update Finished"; +"install.completion.title.verified" = "Install / Update Verified"; +"install.completion.warning.netbsd4" = "NetBSD4 devices may need Start SMB after a later reboot unless the boot hook is patched."; +"install.plan.downtime.netbsd4" = "Usually under a minute; the runtime may start without reboot."; +"install.plan.downtime.none" = "No reboot expected."; +"install.plan.downtime.reboot" = "Several minutes while the Time Capsule reboots."; +"install.plan.row.disk" = "Disk"; +"install.plan.row.expected_downtime" = "Expected Downtime"; +"install.plan.row.remote_actions" = "Remote Actions"; +"install.plan.row.uploads" = "Uploads"; +"install.plan.section.device_actions" = "Device Actions"; +"install.plan.section.files" = "Files"; +"install.plan.section.target" = "Target"; +"install.plan.title.netbsd4" = "Install / Update SMB and Start Runtime"; +"install.plan.title.standard" = "Install / Update SMB"; +"install.state.awaiting_confirmation" = "Review the confirmation dialog before continuing."; +"install.state.deploy_failed" = "Install / Update failed."; +"install.state.deployed" = "Install / Update completed."; +"install.state.deploying" = "Installing files and applying device changes."; +"install.state.idle" = "Create a plan before installing or updating SMB."; +"install.state.plan_failed" = "The install plan could not be created."; +"install.state.plan_ready" = "Review the plan, then run Install / Update."; +"install.state.plan_stale" = "Advanced options changed after this plan was created."; +"install.state.planning" = "Creating an install plan."; +"install.timeline.title" = "Progress"; +"install.timeline.waiting" = "Waiting for backend progress."; +"install.warning.awaiting_confirmation" = "The backend is waiting for explicit confirmation."; +"install.warning.plan_stale" = "Regenerate the plan before installing."; "diagnostics.backend_events" = "Backend Events"; "diagnostics.distribution" = "Distribution"; "diagnostics.helper" = "Helper"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift new file mode 100644 index 00000000..1f62e619 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift @@ -0,0 +1,182 @@ +import SwiftUI + +struct AddDeviceView: View { + @ObservedObject var store: AddDeviceFlowStore + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + topSection + if store.entryMode == .manual { + connectionControls + Spacer(minLength: 0) + } else { + deviceResultsSection + connectionControls + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var topSection: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + Text(L10n.string("add_device.title")) + .font(.title2.weight(.semibold)) + Spacer() + Picker(L10n.string("add_device.connection_method"), selection: Binding( + get: { store.entryMode }, + set: { store.setEntryMode($0) } + )) { + ForEach(AddDeviceEntryMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .frame(width: 360) + } + + HStack { + if store.entryMode == .discover { + Text(store.currentStage?.description ?? L10n.string("add_device.discover.placeholder")) + .foregroundStyle(.secondary) + Button { + store.runDiscover() + } label: { + Label(L10n.string("button.discover"), systemImage: "network") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + } + Label(store.state.title, systemImage: statusIcon) + .foregroundStyle(statusColor) + } + .frame(minHeight: 28, alignment: .center) + } + } + + private var deviceResultsSection: some View { + Group { + if store.entryMode == .discover && !store.devices.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("add_device.discovered_devices")) + .font(.headline) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(store.devices) { device in + Button { + store.select(device) + } label: { + DeviceCandidateRow(device: device, selected: store.selectedDeviceID == device.id) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + } + } + .scrollIndicators(.visible) + .frame(maxWidth: .infinity) + } + } else { + Spacer(minLength: 24) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var connectionControls: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + TextField(L10n.string("add_device.host_or_ip"), text: Binding( + get: { store.hostFieldText }, + set: { store.manualHost = $0 } + )) + .disabled(!store.isHostFieldEditable) + SecureField(L10n.string("add_device.password"), text: $store.password) + .onSubmit { + guard store.canConfigure else { + return + } + store.runConfigure() + } + } + + HStack { + Button { + store.runConfigure() + } label: { + Label(L10n.string("add_device.save_device"), systemImage: "checkmark.circle") + } + .disabled(!store.canConfigure) + + Button { + store.reset() + } label: { + Label(L10n.string("add_device.reset"), systemImage: "arrow.counterclockwise") + } + .disabled(store.isRunning) + } + + if let profile = store.savedProfile { + Label(L10n.format("add_device.saved", profile.title), systemImage: "checkmark.circle") + .foregroundStyle(.green) + } + + if let error = store.error { + ErrorBlock(error: error) + } + } + } + + private var statusIcon: String { + switch store.state { + case .idle, .manualEntry, .passwordEntry: + return "circle" + case .discovering, .configuring, .savingProfile: + return "hourglass" + case .discoveryReady, .saved: + return "checkmark.circle" + case .discoveryEmpty: + return "magnifyingglass" + case .authFailed, .unsupported, .failed: + return "exclamationmark.triangle" + } + } + + private var statusColor: Color { + switch store.state { + case .discoveryReady, .saved: + return .green + case .authFailed, .unsupported, .failed: + return .red + default: + return .secondary + } + } +} + +private struct DeviceCandidateRow: View { + let device: DiscoveredDevice + let selected: Bool + + var body: some View { + HStack { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? Color.accentColor : Color.secondary) + VStack(alignment: .leading) { + Text(device.name) + Text([device.host, device.hostname].filter { !$0.isEmpty }.joined(separator: " ")) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if !device.discoveryModelText.isEmpty { + Text(device.discoveryModelText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 6) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SharedViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SharedViews.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ToolbarIconButton.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ToolbarIconButton.swift new file mode 100644 index 00000000..9d76dcc5 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ToolbarIconButton.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct ToolbarIconButton: View { + let title: String + let systemImage: String + var disabled = false + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button { + guard !disabled else { + return + } + action() + } label: { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(disabled ? Color.secondary.opacity(0.5) : Color.primary) + .frame(width: 28, height: 28) + .background { + Circle() + .fill(isHovered && !disabled ? Color.primary.opacity(0.10) : Color.clear) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(title) + .accessibilityLabel(title) + .accessibilityValue(disabled ? L10n.string("toolbar.disabled") : "") + .onHover { hovering in + isHovered = hovering + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift new file mode 100644 index 00000000..b6a157cc --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct AdvancedTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + @ObservedObject var appStore: AppStore + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("dashboard.tab.advanced")) + .font(.title2.weight(.semibold)) + DeviceProfileEditorView(profile: profile, store: session.profileEditorStore) + SummaryGrid(rows: [ + (L10n.string("advanced.profile_id"), profile.id), + (L10n.string("advanced.config"), profile.configPath), + (L10n.string("advanced.helper"), appStore.backend.helperPath.isEmpty ? L10n.string("value.auto") : appStore.backend.helperPath) + ]) + EventList(events: appStore.backend.events) + } + } +} + +private struct DeviceProfileEditorView: View { + let profile: DeviceProfile + @ObservedObject var store: DeviceProfileEditorStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.string("profile_editor.title")) + .font(.headline) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("profile_editor.display_name")) + .foregroundStyle(.secondary) + TextField(L10n.string("profile_editor.display_name"), text: $store.draft.displayName) + .frame(maxWidth: 360) + } + GridRow { + Text(L10n.string("dashboard.overview.host")) + .foregroundStyle(.secondary) + TextField(L10n.string("dashboard.overview.host"), text: $store.draft.host) + .frame(maxWidth: 360) + } + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.draft.mountWaitSeconds) + .frame(width: 160) + } + } + + HStack { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) + } + + HStack { + Button { + Task { @MainActor in + await store.save(profile: profile) + } + } label: { + Label(L10n.string("profile_editor.save"), systemImage: "square.and.arrow.down") + } + .disabled(!store.canSave(profile: profile)) + + Button { + store.reset(to: profile) + } label: { + Label(L10n.string("profile_editor.reset"), systemImage: "arrow.counterclockwise") + } + .disabled(store.isRunning) + + Label(store.state.title, systemImage: "circle") + .foregroundStyle(.secondary) + } + + ForEach(store.validationErrors, id: \.self) { validationError in + Text(validationError.localizedDescription) + .font(.caption) + .foregroundStyle(.red) + } + + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let error = store.error { + ErrorRecoveryView(error: error) { _ in } + } + } + .padding(.bottom, 8) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift new file mode 100644 index 00000000..42ad4516 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct CheckupTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + let showDiagnostics: () -> Void + + var body: some View { + let store = session.doctorStore + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("dashboard.tab.checkup")) + .font(.title2.weight(.semibold)) + HStack { + TextField(L10n.string("field.bonjour_timeout"), text: $session.doctorStore.bonjourTimeout) + .frame(width: 180) + Button { + session.runCheckup(profile: profile) + } label: { + Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + Label(store.state.title, systemImage: "circle") + } + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let summary = store.summary { + let presentation = CheckupPresentation(summary: summary, state: store.state) + Text(presentation.headline) + .font(.headline) + SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) + ForEach(presentation.groups) { group in + VStack(alignment: .leading, spacing: 4) { + Text(group.domain).font(.headline) + ForEach(Array(group.checks.enumerated()), id: \.offset) { _, check in + HStack { + Text(check.status) + .font(.system(.caption, design: .monospaced)) + .frame(width: 44, alignment: .leading) + Text(check.message) + .font(.caption) + } + } + } + } + } + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } + } + } + } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = session.handleRecoveryAction(action, error: error, profile: profile) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift new file mode 100644 index 00000000..8fb0f5ca --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct DeviceDashboardView: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + @ObservedObject var appStore: AppStore + let showDiagnostics: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Picker("", selection: $session.selectedTab) { + ForEach(DeviceDashboardTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding() + + Divider() + + ScrollView { + Group { + switch session.selectedTab { + case .overview: + OverviewTab(profile: profile, session: session, appStore: appStore) + case .install: + InstallTab(profile: profile, session: session, showDiagnostics: showDiagnostics) + case .checkup: + CheckupTab(profile: profile, session: session, showDiagnostics: showDiagnostics) + case .maintenance: + MaintenanceTab(profile: profile, session: session, showDiagnostics: showDiagnostics) + case .advanced: + AdvancedTab(profile: profile, session: session, appStore: appStore) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashBootHookView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashBootHookView.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift new file mode 100644 index 00000000..b09342fb --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift @@ -0,0 +1,242 @@ +import SwiftUI + +struct InstallTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + let showDiagnostics: () -> Void + + var body: some View { + let store = session.deployStore + let presentation = InstallWorkflowPresentation( + state: store.state, + plan: store.plan, + result: store.result, + error: store.error, + events: store.events, + currentStage: store.currentStage, + profile: profile, + hostWarning: HostCompatibilityPolicy.warning() + ) + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + InstallHeaderView(presentation: presentation) + + ForEach(presentation.notices, id: \.self) { notice in + Label(notice, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + + if let action = presentation.primaryAction { + InstallActionButton(action: action) { + session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + } + .disabled(isDisabled(action, store: store)) + } + + if let timeline = presentation.timeline { + InstallTimelineView(presentation: timeline) + } + + if let plan = presentation.plan { + InstallPlanView(presentation: plan) + } + + if let completion = presentation.completion { + InstallCompletionView(presentation: completion) { action in + session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + } + } + + InstallAdvancedOptionsView(store: store) + + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = session.handleRecoveryAction(action, error: error, profile: profile) + } + + private func isDisabled(_ action: InstallUserAction, store: DeployWorkflowStore) -> Bool { + switch action { + case .createPlan, .regeneratePlan: + return store.isRunning || store.mountWaitValue == nil + case .installUpdate: + return !store.canDeploy + case .openFinder, .runCheckup, .viewDiagnostics: + return false + } + } +} + +private struct InstallHeaderView: View { + let presentation: InstallWorkflowPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(presentation.title) + .font(.title2.weight(.semibold)) + Spacer() + Text(presentation.stateTitle) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) + } + Text(presentation.statusMessage) + .font(.callout) + .foregroundStyle(.secondary) + } + } +} + +private struct InstallActionButton: View { + let action: InstallUserAction + let perform: () -> Void + + var body: some View { + Button(action: perform) { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.borderedProminent) + } +} + +private struct InstallPlanView: View { + let presentation: InstallPlanPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(presentation.title) + .font(.headline) + + ForEach(presentation.sections) { section in + VStack(alignment: .leading, spacing: 6) { + Text(section.title) + .font(.subheadline.weight(.medium)) + SummaryGrid(rows: section.rows.map { ($0.label, $0.value) }) + } + } + + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + } + } +} + +private struct InstallTimelineView: View { + let presentation: InstallTimelinePresentation + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("install.timeline.title")) + .font(.headline) + if presentation.items.isEmpty { + Text(L10n.string("install.timeline.waiting")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(presentation.items) { item in + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon(for: item.state)) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.body.weight(.medium)) + if let detail = item.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + } + + private func icon(for state: OperationTimelineItem.State) -> String { + switch state { + case .pending: + return "circle" + case .running: + return "progress.indicator" + case .succeeded: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + } + } +} + +private struct InstallCompletionView: View { + let presentation: InstallCompletionPresentation + let performAction: (InstallUserAction) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(presentation.title) + .font(.headline) + SummaryGrid(rows: presentation.rows.map { ($0.label, $0.value) }) + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + HStack { + ForEach(presentation.actions) { action in + Button { + performAction(action) + } label: { + Label(action.title, systemImage: action.systemImage) + } + } + } + } + } +} + +private struct InstallAdvancedOptionsView: View { + @ObservedObject var store: DeployWorkflowStore + + var body: some View { + DisclosureGroup(L10n.string("install.advanced_options")) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.nbnsEnabled) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.debugLogging) + } + GridRow { + Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + } + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.mountWait) + .frame(width: 150) + } + } + .padding(.top, 8) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift new file mode 100644 index 00000000..8fab02b4 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift @@ -0,0 +1,169 @@ +import AppKit +import SwiftUI + +struct MaintenanceTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + let showDiagnostics: () -> Void + + var body: some View { + let store = session.maintenanceStore + let presentation = MaintenanceWorkflowPresentation.presentation(for: store.selectedWorkflow) + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("dashboard.tab.maintenance")) + .font(.title2.weight(.semibold)) + Picker(L10n.string("dashboard.tab.maintenance"), selection: $session.maintenanceStore.selectedWorkflow) { + Text(L10n.string("maintenance.workflow.activate")).tag(MaintenanceWorkflow.activate) + Text(L10n.string("maintenance.workflow.uninstall")).tag(MaintenanceWorkflow.uninstall) + Text(L10n.string("maintenance.workflow.fsck")).tag(MaintenanceWorkflow.fsck) + Text(L10n.string("maintenance.workflow.repair_xattrs")).tag(MaintenanceWorkflow.repairXattrs) + } + .pickerStyle(.segmented) + + VStack(alignment: .leading, spacing: 4) { + Text(presentation.title) + .font(.headline) + Text(presentation.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + Label(presentation.risk, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + TextField(L10n.string("field.mount_wait"), text: $session.maintenanceStore.mountWait) + .frame(width: 150) + Toggle(L10n.string("toggle.no_reboot"), isOn: $session.maintenanceStore.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $session.maintenanceStore.noWait) + } + + maintenanceControls(store: store) + FlashBootHookSection(profile: profile) + + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } + } + } + } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = session.handleRecoveryAction(action, error: error, profile: profile) + } + + @ViewBuilder + private func maintenanceControls(store: MaintenanceStore) -> some View { + switch store.selectedWorkflow { + case .activate: + HStack { + Button(L10n.string("maintenance.action.plan_start_smb")) { + if let password = session.maintenancePassword(for: profile) { + store.planActivation(password: password, profile: profile) + } + } + Button(L10n.string("maintenance.action.start_smb")) { + if let password = session.maintenancePassword(for: profile) { + store.runActivation(password: password, profile: profile) + } + } + .disabled(!store.canRunActivation) + Label(store.activateState.title, systemImage: "circle") + } + case .uninstall: + HStack { + Button(L10n.string("maintenance.action.plan_uninstall")) { + if let password = session.maintenancePassword(for: profile) { + store.planUninstall(password: password, profile: profile) + } + } + Button(L10n.string("maintenance.action.uninstall")) { + if let password = session.maintenancePassword(for: profile) { + store.runUninstall(password: password, profile: profile) + } + } + .disabled(!store.canRunUninstall) + Label(store.uninstallState.title, systemImage: "circle") + } + case .fsck: + VStack(alignment: .leading, spacing: 8) { + HStack { + Button(L10n.string("maintenance.action.find_volumes")) { + if let password = session.maintenancePassword(for: profile) { + store.refreshFsckTargets(password: password, profile: profile) + } + } + Button(L10n.string("maintenance.action.plan_disk_repair")) { + if let password = session.maintenancePassword(for: profile) { + store.planFsck(password: password, profile: profile) + } + } + .disabled(!store.canPlanFsck) + Button(L10n.string("maintenance.action.run_disk_repair")) { + if let password = session.maintenancePassword(for: profile) { + store.runFsck(password: password, profile: profile) + } + } + .disabled(!store.canRunFsck) + Label(store.fsckState.title, systemImage: "circle") + } + ForEach(store.fsckTargets) { target in + Button { + store.selectedFsckTargetID = target.id + } label: { + HStack { + Image(systemName: store.selectedFsckTargetID == target.id ? "checkmark.circle.fill" : "circle") + Text(target.name ?? target.device) + Text(target.mountpoint).foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } + } + case .repairXattrs: + VStack(alignment: .leading, spacing: 8) { + HStack { + TextField(L10n.string("field.repair_xattrs_path"), text: $session.maintenanceStore.repairPath) + Button { + chooseRepairPath(store: store) + } label: { + Label(L10n.string("maintenance.action.choose_folder"), systemImage: "folder") + } + } + HStack { + Button(L10n.string("maintenance.action.scan_metadata")) { + store.scanRepairXattrs() + } + Button(L10n.string("maintenance.action.repair_metadata")) { + store.runRepairXattrs() + } + .disabled(!store.canRepairXattrs) + Label(store.repairState.title, systemImage: "circle") + } + if let scan = store.repairScan { + Text(L10n.format("maintenance.repairable_count", scan.repairableCount)) + .foregroundStyle(.secondary) + } + } + } + } + + private func chooseRepairPath(store: MaintenanceStore) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = L10n.string("maintenance.action.choose") + if panel.runModal() == .OK, let url = panel.url { + store.repairPath = url.path + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift new file mode 100644 index 00000000..9a755e8d --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift @@ -0,0 +1,200 @@ +import SwiftUI + +struct OverviewTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + @ObservedObject var appStore: AppStore + + var body: some View { + let summary = session.summary(for: profile) + let presentation = DeviceDashboardOverviewPresentation( + summary: summary, + currentCheckupSummary: session.doctorStore.summary + ) + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let warning = presentation.hostWarning { + WarningBanner(warning: warning) + } + + DashboardHeaderView(presentation: presentation.header) + + DashboardPrimaryActionStrip( + primaryAction: presentation.primaryAction, + secondaryActions: presentation.secondaryActions, + performPrimary: { + session.performPrimaryAction(presentation.primaryAction, profile: profile) + }, + performSecondary: { action in + session.performSecondaryAction(action, profile: profile) + } + ) + + if presentation.requiresPasswordReplacement || session.isReplacingPassword { + PasswordReplacementView(profile: profile, session: session) + } + + if let passwordError = session.passwordError { + Text(passwordError) + .foregroundStyle(.red) + } + + VStack(alignment: .leading, spacing: 10) { + ForEach(presentation.healthSections) { section in + DashboardHealthSectionView(section: section) { action in + session.performSecondaryAction(action, profile: profile) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private struct DashboardHeaderView: View { + let presentation: DeviceDashboardHeaderPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 3) { + Text(presentation.title) + .font(.title2.weight(.semibold)) + Text(presentation.host) + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + StatusBadge(status: presentation.status) + } + + HStack(spacing: 12) { + Label(presentation.lastChecked, systemImage: "clock") + .font(.caption) + .foregroundStyle(.secondary) + Text(L10n.string("dashboard.header.last_checked")) + .font(.caption) + .foregroundStyle(.secondary) + } + + SummaryGrid(rows: presentation.rows.map { ($0.label, $0.value) }) + } + } +} + +private struct StatusBadge: View { + let status: DeviceDisplayStatus + + var body: some View { + Label(status.title, systemImage: status.systemImage) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) + } +} + +private struct DashboardPrimaryActionStrip: View { + let primaryAction: DashboardPrimaryAction + let secondaryActions: [DashboardSecondaryAction] + let performPrimary: () -> Void + let performSecondary: (DashboardSecondaryAction) -> Void + + var body: some View { + HStack(spacing: 8) { + DashboardPrimaryActionButton(action: primaryAction, perform: performPrimary) + + ForEach(secondaryActions) { action in + Button { + performSecondary(action) + } label: { + Label(action.title, systemImage: action.systemImage) + } + } + } + } +} + +private struct DashboardPrimaryActionButton: View { + let action: DashboardPrimaryAction + let perform: () -> Void + + var body: some View { + Button(action: perform) { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.borderedProminent) + } +} + +private struct PasswordReplacementView: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("dashboard.password.title")) + .font(.headline) + HStack { + SecureField(L10n.string("dashboard.replacement_password"), text: $session.replacementPassword) + .onSubmit { + Task { @MainActor in + await session.saveReplacementPassword(for: profile) + } + } + Button { + Task { @MainActor in + await session.saveReplacementPassword(for: profile) + } + } label: { + Label(L10n.string("dashboard.action.save_password"), systemImage: "key") + } + .disabled(session.replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } +} + +private struct DashboardHealthSectionView: View { + let section: DashboardHealthSection + let performAction: (DashboardSecondaryAction) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(section.title) + .font(.headline) + ForEach(section.rows) { row in + HStack(alignment: .top, spacing: 10) { + Label(row.status.title, systemImage: row.status.systemImage) + .font(.caption.weight(.medium)) + .labelStyle(.iconOnly) + .frame(width: 18) + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(row.title) + .font(.body.weight(.medium)) + Spacer() + if let action = row.action { + Button { + performAction(action) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .controlSize(.small) + } + } + Text(row.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift new file mode 100644 index 00000000..0aaf009e --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift @@ -0,0 +1,180 @@ +import SwiftUI + +struct AppReadinessBannerView: View { + @ObservedObject var store: AppReadinessStore + let showDiagnostics: () -> Void + + var body: some View { + switch store.state { + case .idle, .ready: + EmptyView() + case .resolvingBundle, .checkingCapabilities, .validatingInstall: + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(title) + .font(.caption) + if let stage = store.currentStage?.description ?? store.currentStage?.stage { + Text(stage) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.08)) + case .degraded(_, let issues): + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + Text(issues.first?.message ?? L10n.string("readiness.warning.default")) + .font(.caption) + Spacer() + Button(L10n.string("toolbar.diagnostics"), action: showDiagnostics) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.yellow.opacity(0.12)) + case .blocked: + EmptyView() + } + } + + private var title: String { + switch store.state.kind { + case .resolvingBundle: + return L10n.string("readiness.state.resolving_bundle") + case .checkingCapabilities: + return L10n.string("readiness.state.checking_capabilities") + case .validatingInstall: + return L10n.string("readiness.state.validating_install") + default: + return "" + } + } +} + +struct AppReadinessBlockedView: View { + @ObservedObject var store: AppReadinessStore + let showDiagnostics: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Label(L10n.string("readiness.blocked.title"), systemImage: "exclamationmark.octagon") + .font(.title2.weight(.semibold)) + .foregroundStyle(.red) + if case .blocked(let issue) = store.state { + Text(issue.message) + Text(issue.recovery) + .foregroundStyle(.secondary) + } + HStack { + Button { + store.start() + } label: { + Label(L10n.string("recovery.action.retry"), systemImage: "arrow.clockwise") + } + .disabled(!store.canRetry) + + Button { + showDiagnostics() + } label: { + Label(L10n.string("toolbar.diagnostics"), systemImage: "wrench.and.screwdriver") + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +struct AppDiagnosticsView: View { + @ObservedObject var store: AppReadinessStore + let events: [BackendEvent] + @Binding var helperPath: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(L10n.string("diagnostics.title")) + .font(.title2.weight(.semibold)) + Spacer() + Button(L10n.string("action.done")) { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + + TextField(L10n.string("field.helper"), text: $helperPath) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { + Text(L10n.string("diagnostics.state")).foregroundStyle(.secondary) + Text(store.state.kind.title) + } + if let capabilities = store.capabilities { + GridRow { + Text(L10n.string("diagnostics.helper")).foregroundStyle(.secondary) + Text(capabilities.helperVersion) + } + GridRow { + Text(L10n.string("diagnostics.distribution")).foregroundStyle(.secondary) + Text(capabilities.distributionRoot) + .lineLimit(1) + .truncationMode(.middle) + } + } + if let validation = store.validation { + GridRow { + Text(L10n.string("diagnostics.validation")).foregroundStyle(.secondary) + Text(validation.summary) + } + } + } + .font(.caption) + + if !store.issues.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("diagnostics.runtime_issues")) + .font(.headline) + ForEach(store.issues) { issue in + VStack(alignment: .leading, spacing: 2) { + Text(issue.message) + Text(issue.recovery) + .foregroundStyle(.secondary) + } + .font(.caption) + } + } + } + + Text(L10n.string("diagnostics.backend_events")) + .font(.headline) + EventList(events: events) + } + .padding() + .frame(minWidth: 720, minHeight: 520) + } +} + +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/ActivityView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift new file mode 100644 index 00000000..024d56e3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift @@ -0,0 +1,242 @@ +import SwiftUI + +public struct ContentView: View { + @StateObject private var appStore: AppStore + @StateObject private var addDeviceStore: AddDeviceFlowStore + @StateObject private var dashboardStore: DashboardStore + @State private var diagnosticsPresented = false + @State private var profilePendingDeletion: DeviceProfile? + @State private var deleteErrorMessage: String? + + @MainActor + public init() { + let appStore = AppStore() + _appStore = StateObject(wrappedValue: appStore) + _addDeviceStore = StateObject(wrappedValue: AddDeviceFlowStore( + coordinator: appStore.operationCoordinator, + registry: appStore.deviceRegistry, + passwordStore: appStore.passwordStore + )) + _dashboardStore = StateObject(wrappedValue: DashboardStore(appStore: appStore)) + } + + public var body: some View { + NavigationSplitView { + sidebar + } detail: { + VStack(spacing: 0) { + if case .blocked = appStore.appReadinessStore.state { + AppReadinessBlockedView(store: appStore.appReadinessStore) { + diagnosticsPresented = true + } + } else { + AppReadinessBannerView(store: appStore.appReadinessStore) { + diagnosticsPresented = true + } + detail + Divider() + ActivityCompactView( + activityStore: appStore.activityStore, + registry: appStore.deviceRegistry + ) + } + } + .toolbar { + ToolbarItemGroup { + ToolbarIconButton( + title: L10n.string("toolbar.add"), + systemImage: "plus" + ) { + appStore.showAddDevice() + } + ToolbarIconButton( + title: L10n.string("toolbar.diagnostics"), + systemImage: "wrench.and.screwdriver" + ) { + diagnosticsPresented = true + } + ToolbarIconButton( + title: L10n.string("toolbar.forget"), + systemImage: "trash", + disabled: appStore.selectedProfile == nil || appStore.backend.isRunning + ) { + guard let profile = appStore.selectedProfile else { + return + } + profilePendingDeletion = profile + } + ToolbarIconButton( + title: L10n.string("toolbar.cancel"), + systemImage: "xmark.circle", + disabled: !appStore.backend.canCancel + ) { + appStore.operationCoordinator.cancel() + } + } + } + } + .frame(minWidth: 1080, minHeight: 720) + .task { + await appStore.start() + } + .onChange(of: addDeviceStore.savedProfile) { profile in + guard let profile else { return } + appStore.select(profile) + } + .sheet(isPresented: $diagnosticsPresented) { + AppDiagnosticsView( + store: appStore.appReadinessStore, + events: appStore.backend.events, + helperPath: Binding( + get: { appStore.backend.helperPath }, + set: { appStore.backend.helperPath = $0 } + ) + ) + } + .confirmationDialog( + L10n.string("dialog.forget.title"), + isPresented: deleteConfirmationPresented, + presenting: profilePendingDeletion + ) { profile in + Button(L10n.format("dialog.forget.action", profile.title), role: .destructive) { + Task { @MainActor in + do { + try await appStore.forget(profile) + profilePendingDeletion = nil + } catch { + deleteErrorMessage = error.localizedDescription + } + } + } + Button(L10n.string("action.cancel"), role: .cancel) { + profilePendingDeletion = nil + } + } message: { profile in + Text(L10n.format("dialog.forget.message", profile.title)) + } + .alert(L10n.string("dialog.forget.error_title"), isPresented: deleteErrorPresented) { + Button(L10n.string("action.ok"), role: .cancel) { + deleteErrorMessage = nil + } + } message: { + Text(deleteErrorMessage ?? "") + } + .alert( + appStore.backend.pendingConfirmation?.title ?? "", + isPresented: confirmationPresented, + presenting: appStore.backend.pendingConfirmation + ) { confirmation in + Button(confirmation.actionTitle, role: .destructive) { + appStore.backend.confirmPending() + } + Button(L10n.string("action.cancel"), role: .cancel) { + appStore.backend.pendingConfirmation = nil + } + } message: { confirmation in + Text(confirmation.message) + } + } + + private var deleteConfirmationPresented: Binding { + Binding( + get: { profilePendingDeletion != nil }, + set: { isPresented in + if !isPresented { + profilePendingDeletion = nil + } + } + ) + } + + private var deleteErrorPresented: Binding { + Binding( + get: { deleteErrorMessage != nil }, + set: { isPresented in + if !isPresented { + deleteErrorMessage = nil + } + } + ) + } + + private var confirmationPresented: Binding { + Binding( + get: { appStore.backend.pendingConfirmation != nil }, + set: { isPresented in + if !isPresented { + appStore.backend.pendingConfirmation = nil + } + } + ) + } + + private var sidebarSelection: Binding { + Binding( + get: { + if appStore.showingAddDevice { + return "add" + } + if let selectedDeviceID = appStore.selectedDeviceID { + return "device:\(selectedDeviceID)" + } + return "all" + }, + set: { value in + guard let value else { return } + if value == "add" { + appStore.showAddDevice() + } else if value == "all" { + appStore.selectedDeviceID = nil + appStore.showingAddDevice = false + } else if value.hasPrefix("device:") { + let id = String(value.dropFirst("device:".count)) + if let profile = appStore.deviceRegistry.profile(id: id) { + appStore.select(profile) + } + } + } + ) + } + + private var sidebar: some View { + List(selection: sidebarSelection) { + Label(L10n.string("sidebar.all_time_capsules"), systemImage: "externaldrive.connected.to.line.below") + .tag("all") + + Section(L10n.string("sidebar.devices")) { + ForEach(appStore.deviceRegistry.profiles) { profile in + DeviceSidebarRow( + profile: profile, + summary: appStore.dashboardSummary(for: profile) + ) + .tag("device:\(profile.id)") + } + } + + Section { + Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") + .tag("add") + } + } + .navigationTitle("TimeCapsuleSMB") + .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 360) + } + + @ViewBuilder + private var detail: some View { + if appStore.showingAddDevice { + AddDeviceView(store: addDeviceStore) + } else if let profile = appStore.selectedProfile { + DeviceDashboardView( + profile: profile, + session: dashboardStore.session(for: profile), + appStore: appStore, + showDiagnostics: { + diagnosticsPresented = true + } + ) + } else { + DeviceListOverviewView(appStore: appStore) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift new file mode 100644 index 00000000..417ea363 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct DeviceListOverviewView: View { + @ObservedObject var appStore: AppStore + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(appStore.deviceRegistry.profiles.isEmpty ? L10n.string("overview.empty.title") : L10n.string("sidebar.all_time_capsules")) + .font(.title2.weight(.semibold)) + if appStore.deviceRegistry.profiles.isEmpty { + Text(L10n.string("overview.empty.message")) + .foregroundStyle(.secondary) + Button { + appStore.showAddDevice() + } label: { + Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") + } + } else { + ForEach(appStore.deviceRegistry.profiles) { profile in + let summary = appStore.dashboardSummary(for: profile) + Button { + appStore.select(profile) + } label: { + HStack { + VStack(alignment: .leading) { + Text(profile.title) + .font(.body.weight(.medium)) + Text(profile.host) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Label(summary.displayStatus.title, systemImage: summary.displayStatus.systemImage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + Divider() + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SidebarView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SidebarView.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppReadinessStore.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppReadinessStore.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift new file mode 100644 index 00000000..4ef8b54d --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift @@ -0,0 +1,464 @@ +import Foundation + +enum DashboardSecondaryAction: String, Equatable, Hashable, Identifiable { + case runCheckup + case installUpdate + case openFinder + case replacePassword + case viewCheckup + case startSMB + case advanced + + var id: String { rawValue } + + var title: String { + switch self { + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .installUpdate: + return L10n.string("dashboard.action.install_update_smb") + case .openFinder: + return L10n.string("dashboard.action.open_finder") + case .replacePassword: + return L10n.string("dashboard.action.replace_password") + case .viewCheckup: + return L10n.string("dashboard.action.view_checkup") + case .startSMB: + return L10n.string("dashboard.action.start_smb") + case .advanced: + return L10n.string("dashboard.action.advanced") + } + } + + var systemImage: String { + switch self { + case .runCheckup: + return "stethoscope" + case .installUpdate: + return "square.and.arrow.up" + case .openFinder: + return "folder" + case .replacePassword: + return "key" + case .viewCheckup: + return "list.bullet.clipboard" + case .startSMB: + return "play.circle" + case .advanced: + return "gearshape" + } + } +} + +struct DeviceDashboardHeaderPresentation: Equatable { + let title: String + let host: String + let status: DeviceDisplayStatus + let lastChecked: String + let rows: [PresentationRow] + + init(summary: DeviceDashboardSummary) { + let profile = summary.profile + self.title = profile.title + self.host = profile.host + self.status = summary.displayStatus + self.lastChecked = profile.lastCheckup + .map { Self.formattedDate($0.checkedAt) } + ?? L10n.string("value.never") + self.rows = [ + PresentationRow(label: L10n.string("dashboard.overview.model"), value: profile.model ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("dashboard.overview.generation"), value: profile.deviceGeneration ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("dashboard.overview.payload"), value: profile.payloadFamily ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("dashboard.overview.password"), value: summary.passwordState.title), + PresentationRow(label: L10n.string("dashboard.overview.last_install"), value: profile.lastDeploy?.summary ?? L10n.string("value.never")) + ] + } + + private static func formattedDate(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .short) + } +} + +enum DashboardHealthDomain: String, CaseIterable, Equatable, Identifiable { + case connection + case runtime + case finderBonjour + case smbAuth + case timeMachine + + var id: String { rawValue } + + var title: String { + switch self { + case .connection: + return L10n.string("dashboard.health.connection") + case .runtime: + return L10n.string("dashboard.health.runtime") + case .finderBonjour: + return L10n.string("dashboard.health.finder_bonjour") + case .smbAuth: + return L10n.string("dashboard.health.smb_auth") + case .timeMachine: + return L10n.string("dashboard.health.time_machine") + } + } + + fileprivate var checkupDomains: Set { + switch self { + case .connection: + return ["connection", "device", "ssh"] + case .runtime: + return ["runtime", "process", "service"] + case .finderBonjour: + return ["bonjour", "finder", "advertising"] + case .smbAuth: + return ["smb", "smb auth", "auth"] + case .timeMachine: + return ["time machine", "timemachine"] + } + } +} + +enum DashboardHealthStatus: String, Equatable { + case unknown + case good + case warning + case failed + case running + + var title: String { + switch self { + case .unknown: + return L10n.string("dashboard.health.status.unknown") + case .good: + return L10n.string("dashboard.health.status.good") + case .warning: + return L10n.string("dashboard.health.status.warning") + case .failed: + return L10n.string("dashboard.health.status.failed") + case .running: + return L10n.string("dashboard.health.status.running") + } + } + + var systemImage: String { + switch self { + case .unknown: + return "questionmark.circle" + case .good: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + case .running: + return "progress.indicator" + } + } +} + +struct DashboardHealthRow: Equatable, Identifiable { + let id: String + let title: String + let detail: String + let status: DashboardHealthStatus + let action: DashboardSecondaryAction? + + init( + id: String, + title: String, + detail: String, + status: DashboardHealthStatus, + action: DashboardSecondaryAction? = nil + ) { + self.id = id + self.title = title + self.detail = detail + self.status = status + self.action = action + } +} + +struct DashboardHealthSection: Equatable, Identifiable { + let domain: DashboardHealthDomain + let rows: [DashboardHealthRow] + + var id: String { domain.rawValue } + var title: String { domain.title } +} + +struct DeviceDashboardOverviewPresentation: Equatable { + let header: DeviceDashboardHeaderPresentation + let primaryAction: DashboardPrimaryAction + let secondaryActions: [DashboardSecondaryAction] + let healthSections: [DashboardHealthSection] + let hostWarning: HostCompatibilityWarning? + let requiresPasswordReplacement: Bool + + init(summary: DeviceDashboardSummary, currentCheckupSummary: DoctorSummary? = nil) { + self.header = DeviceDashboardHeaderPresentation(summary: summary) + self.primaryAction = summary.primaryAction + self.secondaryActions = Self.secondaryActions(for: summary) + self.healthSections = Self.healthSections(for: summary, currentCheckupSummary: currentCheckupSummary) + self.hostWarning = summary.hostWarning + self.requiresPasswordReplacement = Self.requiresPasswordReplacement(summary.passwordState) + } + + private static func secondaryActions(for summary: DeviceDashboardSummary) -> [DashboardSecondaryAction] { + var actions: [DashboardSecondaryAction] = [] + switch summary.primaryAction { + case .replacePassword: + actions.append(.runCheckup) + case .runCheckup: + actions.append(.installUpdate) + case .installSMB: + actions.append(.runCheckup) + case .viewCheckup: + actions.append(.runCheckup) + case .openSMB: + actions.append(.runCheckup) + } + if summary.profile.lastDeploy != nil && summary.primaryAction != .openSMB { + actions.append(.openFinder) + } + if !requiresPasswordReplacement(summary.passwordState) { + actions.append(.replacePassword) + } + actions.append(.advanced) + return actions.removingDuplicates() + } + + private static func requiresPasswordReplacement(_ passwordState: DevicePasswordState) -> Bool { + switch passwordState { + case .unknown, .missing, .invalid, .keychainUnavailable: + return true + case .available: + return false + } + } + + private static func healthSections( + for summary: DeviceDashboardSummary, + currentCheckupSummary: DoctorSummary? + ) -> [DashboardHealthSection] { + [ + DashboardHealthSection(domain: .connection, rows: [connectionRow(for: summary)]), + DashboardHealthSection(domain: .runtime, rows: [runtimeRow(for: summary, currentCheckupSummary: currentCheckupSummary)]), + DashboardHealthSection(domain: .finderBonjour, rows: [ + domainRow(domain: .finderBonjour, summary: summary, currentCheckupSummary: currentCheckupSummary) + ]), + DashboardHealthSection(domain: .smbAuth, rows: [ + domainRow(domain: .smbAuth, summary: summary, currentCheckupSummary: currentCheckupSummary) + ]), + DashboardHealthSection(domain: .timeMachine, rows: [ + timeMachineRow(for: summary, currentCheckupSummary: currentCheckupSummary) + ]) + ] + } + + private static func connectionRow(for summary: DeviceDashboardSummary) -> DashboardHealthRow { + switch summary.displayStatus { + case .checking, .installing, .maintaining: + return DashboardHealthRow( + id: "connection-running", + title: DashboardHealthDomain.connection.title, + detail: L10n.string("dashboard.health.connection.running"), + status: .running, + action: .viewCheckup + ) + default: + break + } + + switch summary.passwordState { + case .available: + return DashboardHealthRow( + id: "connection-password-available", + title: DashboardHealthDomain.connection.title, + detail: L10n.string("dashboard.health.connection.password_available"), + status: .good + ) + case .unknown, .missing: + return DashboardHealthRow( + id: "connection-password-missing", + title: DashboardHealthDomain.connection.title, + detail: L10n.string("dashboard.health.connection.password_missing"), + status: .warning, + action: .replacePassword + ) + case .invalid: + return DashboardHealthRow( + id: "connection-password-invalid", + title: DashboardHealthDomain.connection.title, + detail: L10n.string("dashboard.health.connection.password_invalid"), + status: .failed, + action: .replacePassword + ) + case .keychainUnavailable: + return DashboardHealthRow( + id: "connection-keychain-unavailable", + title: DashboardHealthDomain.connection.title, + detail: L10n.string("dashboard.health.connection.keychain_unavailable"), + status: .failed, + action: .replacePassword + ) + } + } + + private static func runtimeRow( + for summary: DeviceDashboardSummary, + currentCheckupSummary: DoctorSummary? + ) -> DashboardHealthRow { + if summary.displayStatus == .installing { + return DashboardHealthRow( + id: "runtime-installing", + title: DashboardHealthDomain.runtime.title, + detail: L10n.string("dashboard.health.runtime.installing"), + status: .running + ) + } + if summary.displayStatus == .activationNeeded { + return DashboardHealthRow( + id: "runtime-activation-needed", + title: DashboardHealthDomain.runtime.title, + detail: L10n.string("dashboard.health.runtime.activation_needed"), + status: .warning, + action: .startSMB + ) + } + if let signal = checkupSignal(for: .runtime, summary: currentCheckupSummary) { + return DashboardHealthRow( + id: "runtime-checkup", + title: DashboardHealthDomain.runtime.title, + detail: signal.detail, + status: signal.status, + action: signal.status == .good ? nil : .viewCheckup + ) + } + guard let lastDeploy = summary.profile.lastDeploy else { + return DashboardHealthRow( + id: "runtime-not-installed", + title: DashboardHealthDomain.runtime.title, + detail: L10n.string("dashboard.health.runtime.not_installed"), + status: .warning, + action: .installUpdate + ) + } + if lastDeploy.verified == true { + return DashboardHealthRow( + id: "runtime-installed", + title: DashboardHealthDomain.runtime.title, + detail: lastDeploy.summary, + status: .good, + action: .openFinder + ) + } + return DashboardHealthRow( + id: "runtime-installed-unverified", + title: DashboardHealthDomain.runtime.title, + detail: lastDeploy.summary, + status: .warning, + action: .runCheckup + ) + } + + private static func domainRow( + domain: DashboardHealthDomain, + summary: DeviceDashboardSummary, + currentCheckupSummary: DoctorSummary? + ) -> DashboardHealthRow { + if let signal = checkupSignal(for: domain, summary: currentCheckupSummary) { + return DashboardHealthRow( + id: "\(domain.rawValue)-current-checkup", + title: domain.title, + detail: signal.detail, + status: signal.status, + action: signal.status == .good ? nil : .viewCheckup + ) + } + guard let lastCheckup = summary.profile.lastCheckup else { + return DashboardHealthRow( + id: "\(domain.rawValue)-unchecked", + title: domain.title, + detail: L10n.string("dashboard.health.unchecked"), + status: .unknown, + action: .runCheckup + ) + } + return DashboardHealthRow( + id: "\(domain.rawValue)-snapshot", + title: domain.title, + detail: lastCheckup.summary, + status: snapshotStatus(lastCheckup), + action: snapshotStatus(lastCheckup) == .good ? nil : .viewCheckup + ) + } + + private static func timeMachineRow( + for summary: DeviceDashboardSummary, + currentCheckupSummary: DoctorSummary? + ) -> DashboardHealthRow { + if let hostWarning = summary.hostWarning { + return DashboardHealthRow( + id: "time-machine-host-warning", + title: DashboardHealthDomain.timeMachine.title, + detail: hostWarning.message, + status: .warning + ) + } + return domainRow(domain: .timeMachine, summary: summary, currentCheckupSummary: currentCheckupSummary) + } + + private static func checkupSignal( + for domain: DashboardHealthDomain, + summary: DoctorSummary? + ) -> (status: DashboardHealthStatus, detail: String)? { + guard let summary else { + return nil + } + let groups = summary.groups.filter { group in + domain.checkupDomains.contains(group.domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + guard !groups.isEmpty else { + return nil + } + let checks = groups.flatMap(\.checks) + let passCount = checks.filter { $0.status == "PASS" }.count + let warnCount = checks.filter { $0.status == "WARN" }.count + let failCount = checks.filter { $0.status == "FAIL" }.count + let status: DashboardHealthStatus + if failCount > 0 { + status = .failed + } else if warnCount > 0 { + status = .warning + } else if passCount > 0 { + status = .good + } else { + status = .unknown + } + return ( + status: status, + detail: L10n.format("dashboard.health.check_counts", passCount, warnCount, failCount) + ) + } + + private static func snapshotStatus(_ snapshot: DeviceCheckupSnapshot) -> DashboardHealthStatus { + if snapshot.failCount > 0 || snapshot.state == .failed || snapshot.state == .runFailed { + return .failed + } + if snapshot.warnCount > 0 || snapshot.state == .warning { + return .warning + } + if snapshot.passCount > 0 || snapshot.state == .passed { + return .good + } + return .unknown + } +} + +private extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift similarity index 60% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift index 5fc44861..d1d83fb8 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift @@ -1,5 +1,51 @@ import Foundation +enum DashboardPrimaryAction: String, Equatable { + case replacePassword + case runCheckup + case installSMB + case viewCheckup + case openSMB + + var title: String { + switch self { + case .replacePassword: + return L10n.string("dashboard.action.replace_password") + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .installSMB: + return L10n.string("dashboard.action.install_update_smb") + case .viewCheckup: + return L10n.string("dashboard.action.view_checkup") + case .openSMB: + return L10n.string("dashboard.action.open_smb") + } + } + + var systemImage: String { + switch self { + case .replacePassword: + return "key" + case .runCheckup: + return "stethoscope" + case .installSMB: + return "square.and.arrow.up" + case .viewCheckup: + return "list.bullet.clipboard" + case .openSMB: + return "folder" + } + } +} + +struct DeviceDashboardSummary: Equatable { + let profile: DeviceProfile + let passwordState: DevicePasswordState + let displayStatus: DeviceDisplayStatus + let primaryAction: DashboardPrimaryAction + let hostWarning: HostCompatibilityWarning? +} + struct PresentationRow: Equatable, Identifiable { var id: String { "\(label):\(value)" @@ -9,45 +55,6 @@ struct PresentationRow: Equatable, Identifiable { let value: String } -struct DeployPlanPresentation: Equatable { - let title: String - let summaryRows: [PresentationRow] - let advancedRows: [PresentationRow] - let warnings: [String] - - init(plan: DeployPlanPayload, profile: DeviceProfile, hostWarning: HostCompatibilityWarning? = nil) { - self.title = plan.netbsd4 - ? L10n.string("deploy.presentation.title.netbsd4") - : L10n.string("deploy.presentation.title.standard") - self.summaryRows = [ - PresentationRow(label: L10n.string("deploy.presentation.row.target"), value: profile.title), - PresentationRow(label: L10n.string("deploy.presentation.row.host"), value: plan.host), - PresentationRow(label: L10n.string("deploy.presentation.row.payload"), value: plan.payloadFamily ?? profile.payloadFamily ?? L10n.string("value.unknown")), - PresentationRow(label: L10n.string("deploy.presentation.row.disk_location"), value: plan.volumeRoot ?? plan.payloadDir), - PresentationRow(label: L10n.string("deploy.presentation.row.reboot"), value: plan.requiresReboot ? L10n.string("value.required") : L10n.string("value.not_required")), - PresentationRow( - label: L10n.string("deploy.presentation.row.expected_changes"), - value: L10n.format("deploy.presentation.expected_changes", plan.uploads.count, plan.postUploadActions.count) - ) - ] - self.advancedRows = [ - PresentationRow(label: L10n.string("deploy.presentation.row.payload_directory"), value: plan.payloadDir), - PresentationRow(label: L10n.string("deploy.presentation.row.pre_upload_actions"), value: "\(plan.preUploadActions.count)"), - PresentationRow(label: L10n.string("deploy.presentation.row.post_upload_actions"), value: "\(plan.postUploadActions.count)"), - PresentationRow(label: L10n.string("deploy.presentation.row.activation_actions"), value: "\(plan.activationActions.count)"), - PresentationRow(label: L10n.string("deploy.presentation.row.post_install_checks"), value: plan.postDeployChecks.map(\.description).joined(separator: ", ")) - ] - var warnings: [String] = [] - if plan.netbsd4 { - warnings.append(L10n.string("deploy.presentation.warning.netbsd4_activation")) - } - if let hostWarning { - warnings.append(hostWarning.message) - } - self.warnings = warnings - } -} - struct CheckupPresentation: Equatable { let headline: String let summaryRows: [PresentationRow] diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift new file mode 100644 index 00000000..c444d15c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift @@ -0,0 +1,51 @@ +import Combine +import Foundation + +@MainActor +final class DashboardStore: ObservableObject { + let appStore: AppStore + + private var sessions: [DeviceProfile.ID: DeviceDashboardSession] = [:] + private var cancellables: Set = [] + + init(appStore: AppStore) { + self.appStore = appStore + appStore.deviceRegistry.$profiles + .sink { [weak self] profiles in + Task { @MainActor in + self?.pruneSessions(profiles: profiles) + } + } + .store(in: &cancellables) + appStore.operationCoordinator.$activeOperation + .sink { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.pruneSessions(profiles: self.appStore.deviceRegistry.profiles) + } + } + .store(in: &cancellables) + } + + func session(for profile: DeviceProfile) -> DeviceDashboardSession { + if let session = sessions[profile.id] { + return session + } + let session = DeviceDashboardSession(profile: profile, appStore: appStore) + sessions[profile.id] = session + objectWillChange.send() + return session + } + + func hasSession(for profileID: DeviceProfile.ID) -> Bool { + sessions[profileID] != nil + } + + private func pruneSessions(profiles: [DeviceProfile]) { + let existingIDs = Set(profiles.map(\.id)) + let activeProfileID = appStore.operationCoordinator.activeOperation?.profileID + sessions = sessions.filter { id, _ in + existingIDs.contains(id) || id == activeProfileID + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift similarity index 78% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index d4ea520d..27b9b81d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -1,39 +1,12 @@ import Combine import Foundation -#if canImport(AppKit) -import AppKit -#endif - -enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { - case overview - case install - case checkup - case maintenance - case advanced - - var id: String { rawValue } - - var title: String { - switch self { - case .overview: - return L10n.string("dashboard.tab.overview") - case .install: - return L10n.string("dashboard.tab.install") - case .checkup: - return L10n.string("dashboard.tab.checkup") - case .maintenance: - return L10n.string("dashboard.tab.maintenance") - case .advanced: - return L10n.string("dashboard.tab.advanced") - } - } -} @MainActor final class DeviceDashboardSession: ObservableObject, Identifiable { let id: DeviceProfile.ID @Published var selectedTab: DeviceDashboardTab = .overview @Published var replacementPassword = "" + @Published var isReplacingPassword = false @Published private(set) var passwordError: String? let appStore: AppStore @@ -42,13 +15,19 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { var maintenanceStore: MaintenanceStore let profileEditorStore: DeviceProfileEditorStore + private let urlOpener: URLOpening private var activeCheckupOperation: ActiveOperation? private var activeDeployOperation: ActiveOperation? private var cancellables: Set = [] - init(profile: DeviceProfile, appStore: AppStore) { + init( + profile: DeviceProfile, + appStore: AppStore, + urlOpener: URLOpening = WorkspaceURLOpener() + ) { self.id = profile.id self.appStore = appStore + self.urlOpener = urlOpener self.deployStore = DeployWorkflowStore(coordinator: appStore.operationCoordinator) self.doctorStore = DoctorStore(coordinator: appStore.operationCoordinator) self.maintenanceStore = MaintenanceStore(coordinator: appStore.operationCoordinator) @@ -63,9 +42,78 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { appStore.dashboardSummary(for: profile) } + func performPrimaryAction(_ action: DashboardPrimaryAction, profile: DeviceProfile) { + switch action { + case .replacePassword: + showPasswordReplacement() + case .runCheckup: + runCheckup(profile: profile) + case .installSMB: + runInstallPlan(profile: profile) + case .viewCheckup: + selectedTab = .checkup + case .openSMB: + openSMBAddress(for: profile) + } + } + + func performSecondaryAction(_ action: DashboardSecondaryAction, profile: DeviceProfile) { + switch action { + case .runCheckup: + runCheckup(profile: profile) + case .installUpdate: + runInstallPlan(profile: profile) + case .openFinder: + openSMBAddress(for: profile) + case .replacePassword: + showPasswordReplacement() + case .viewCheckup: + selectedTab = .checkup + case .startSMB: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .activate + case .advanced: + selectedTab = .advanced + } + } + + func performInstallAction(_ action: InstallUserAction, profile: DeviceProfile, showDiagnostics: () -> Void) { + switch action { + case .createPlan, .regeneratePlan: + runInstallPlan(profile: profile) + case .installUpdate: + runInstall(profile: profile) + case .openFinder: + openSMBAddress(for: profile) + case .runCheckup: + runCheckup(profile: profile) + case .viewDiagnostics: + showDiagnostics() + } + } + + func saveReplacementPassword(for profile: DeviceProfile) async { + let password = replacementPassword + guard !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + passwordError = L10n.string("password.error.required") + isReplacingPassword = true + return + } + do { + try await appStore.savePassword(password, for: profile) + replacementPassword = "" + passwordError = nil + isReplacingPassword = false + } catch { + passwordError = error.localizedDescription + isReplacingPassword = true + } + } + func runCheckup(profile: DeviceProfile) { guard let password = appStore.password(for: profile) else { passwordError = L10n.string("password.error.required") + isReplacingPassword = true return } passwordError = nil @@ -78,6 +126,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { func runInstallPlan(profile: DeviceProfile) { guard let password = appStore.password(for: profile) else { passwordError = L10n.string("password.error.required") + isReplacingPassword = true return } passwordError = nil @@ -88,6 +137,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { func runInstall(profile: DeviceProfile) { guard let password = appStore.password(for: profile) else { passwordError = L10n.string("password.error.required") + isReplacingPassword = true return } passwordError = nil @@ -100,6 +150,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { func maintenancePassword(for profile: DeviceProfile) -> String? { guard let password = appStore.password(for: profile) else { passwordError = L10n.string("password.error.required") + isReplacingPassword = true return nil } passwordError = nil @@ -135,7 +186,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { maintenanceStore.selectedWorkflow = .repairXattrs return true case .replacePassword: - selectedTab = .overview + showPasswordReplacement() return true case .openFinder: openSMBAddress(for: profile) @@ -145,6 +196,13 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } } + private func showPasswordReplacement() { + replacementPassword = "" + passwordError = nil + isReplacingPassword = true + selectedTab = .overview + } + func applyProfileSettings(_ settings: DeviceProfileSettings) { deployStore.nbnsEnabled = settings.nbnsEnabled deployStore.debugLogging = settings.debugLogging @@ -241,9 +299,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { guard !host.isEmpty, let url = URL(string: "smb://\(host)") else { return } - #if canImport(AppKit) - NSWorkspace.shared.open(url) - #endif + urlOpener.open(url) } private func forwardChildChanges() { @@ -318,52 +374,3 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } } } - -@MainActor -final class DashboardStore: ObservableObject { - let appStore: AppStore - - private var sessions: [DeviceProfile.ID: DeviceDashboardSession] = [:] - private var cancellables: Set = [] - - init(appStore: AppStore) { - self.appStore = appStore - appStore.deviceRegistry.$profiles - .sink { [weak self] profiles in - Task { @MainActor in - self?.pruneSessions(profiles: profiles) - } - } - .store(in: &cancellables) - appStore.operationCoordinator.$activeOperation - .sink { [weak self] _ in - Task { @MainActor in - guard let self else { return } - self.pruneSessions(profiles: self.appStore.deviceRegistry.profiles) - } - } - .store(in: &cancellables) - } - - func session(for profile: DeviceProfile) -> DeviceDashboardSession { - if let session = sessions[profile.id] { - return session - } - let session = DeviceDashboardSession(profile: profile, appStore: appStore) - sessions[profile.id] = session - objectWillChange.send() - return session - } - - func hasSession(for profileID: DeviceProfile.ID) -> Bool { - sessions[profileID] != nil - } - - private func pruneSessions(profiles: [DeviceProfile]) { - let existingIDs = Set(profiles.map(\.id)) - let activeProfileID = appStore.operationCoordinator.activeOperation?.profileID - sessions = sessions.filter { id, _ in - existingIDs.contains(id) || id == activeProfileID - } - } -} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift new file mode 100644 index 00000000..99509dd0 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift @@ -0,0 +1,26 @@ +import Foundation + +enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { + case overview + case install + case checkup + case maintenance + case advanced + + var id: String { rawValue } + + var title: String { + switch self { + case .overview: + return L10n.string("dashboard.tab.overview") + case .install: + return L10n.string("dashboard.tab.install") + case .checkup: + return L10n.string("dashboard.tab.checkup") + case .maintenance: + return L10n.string("dashboard.tab.maintenance") + case .advanced: + return L10n.string("dashboard.tab.advanced") + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashWorkflowStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift new file mode 100644 index 00000000..571e909f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift @@ -0,0 +1,228 @@ +import Foundation + +typealias InstallPlanRow = PresentationRow + +struct InstallPlanSection: Equatable, Identifiable { + let title: String + let rows: [InstallPlanRow] + + var id: String { title } +} + +struct InstallPlanPresentation: Equatable { + let title: String + let sections: [InstallPlanSection] + let warnings: [String] + + init(plan: DeployPlanPayload, profile: DeviceProfile, hostWarning: HostCompatibilityWarning? = nil) { + self.title = plan.netbsd4 + ? L10n.string("install.plan.title.netbsd4") + : L10n.string("install.plan.title.standard") + self.sections = [ + InstallPlanSection(title: L10n.string("install.plan.section.target"), rows: [ + InstallPlanRow(label: L10n.string("deploy.presentation.row.target"), value: profile.title), + InstallPlanRow(label: L10n.string("deploy.presentation.row.host"), value: plan.host), + InstallPlanRow(label: L10n.string("deploy.presentation.row.payload"), value: plan.payloadFamily ?? profile.payloadFamily ?? L10n.string("value.unknown")) + ]), + InstallPlanSection(title: L10n.string("install.plan.section.files"), rows: [ + InstallPlanRow(label: L10n.string("install.plan.row.disk"), value: plan.volumeRoot ?? L10n.string("value.unknown")), + InstallPlanRow(label: L10n.string("deploy.presentation.row.payload_directory"), value: plan.payloadDir), + InstallPlanRow(label: L10n.string("install.plan.row.uploads"), value: "\(plan.uploads.count)") + ]), + InstallPlanSection(title: L10n.string("install.plan.section.device_actions"), rows: [ + InstallPlanRow(label: L10n.string("deploy.presentation.row.reboot"), value: plan.requiresReboot ? L10n.string("value.required") : L10n.string("value.not_required")), + InstallPlanRow(label: L10n.string("install.plan.row.expected_downtime"), value: Self.expectedDowntime(plan: plan)), + InstallPlanRow(label: L10n.string("install.plan.row.remote_actions"), value: "\(plan.preUploadActions.count + plan.postUploadActions.count + plan.activationActions.count)"), + InstallPlanRow(label: L10n.string("deploy.presentation.row.post_install_checks"), value: "\(plan.postDeployChecks.count)") + ]) + ] + var warnings: [String] = [] + if plan.netbsd4 { + warnings.append(L10n.string("deploy.presentation.warning.netbsd4_activation")) + } + if let hostWarning { + warnings.append(hostWarning.message) + } + self.warnings = warnings + } + + private static func expectedDowntime(plan: DeployPlanPayload) -> String { + if plan.requiresReboot { + return L10n.string("install.plan.downtime.reboot") + } + if plan.netbsd4 { + return L10n.string("install.plan.downtime.netbsd4") + } + return L10n.string("install.plan.downtime.none") + } +} + +enum InstallUserAction: String, Equatable, Identifiable { + case createPlan + case regeneratePlan + case installUpdate + case openFinder + case runCheckup + case viewDiagnostics + + var id: String { rawValue } + + var title: String { + switch self { + case .createPlan: + return L10n.string("install.action.create_plan") + case .regeneratePlan: + return L10n.string("install.action.regenerate_plan") + case .installUpdate: + return L10n.string("install.action.install_update") + case .openFinder: + return L10n.string("dashboard.action.open_finder") + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .viewDiagnostics: + return L10n.string("recovery.action.open_diagnostics") + } + } + + var systemImage: String { + switch self { + case .createPlan, .regeneratePlan: + return "doc.text.magnifyingglass" + case .installUpdate: + return "square.and.arrow.up" + case .openFinder: + return "folder" + case .runCheckup: + return "stethoscope" + case .viewDiagnostics: + return "wrench.and.screwdriver" + } + } +} + +struct InstallTimelinePresentation: Equatable { + let items: [OperationTimelineItem] + + init(events: [BackendEvent], currentStage: OperationStageState?) { + var items = OperationTimelineBuilder.timeline(from: events) + .filter { $0.operation == "deploy" } + if items.isEmpty, let currentStage { + items = [ + OperationTimelineItem( + id: "current:\(currentStage.operation):\(currentStage.stage)", + operation: currentStage.operation, + title: currentStage.stage, + detail: currentStage.description, + state: .running, + risk: currentStage.risk, + cancellable: currentStage.cancellable + ) + ] + } + self.items = items + } +} + +struct InstallCompletionPresentation: Equatable { + let title: String + let rows: [PresentationRow] + let warnings: [String] + let actions: [InstallUserAction] + + init(result: DeployResultPayload) { + self.title = result.verified == true + ? L10n.string("install.completion.title.verified") + : L10n.string("install.completion.title.finished") + self.rows = [ + PresentationRow(label: L10n.string("deploy.result.verified"), value: result.verified == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.reboot_requested"), value: result.rebootRequested == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.message"), value: result.message ?? result.summary) + ] + var warnings: [String] = [] + if result.netbsd4 { + warnings.append(L10n.string("install.completion.warning.netbsd4")) + } + self.warnings = warnings + self.actions = [.openFinder, .runCheckup, .viewDiagnostics] + } +} + +struct InstallWorkflowPresentation: Equatable { + let title: String + let stateTitle: String + let statusMessage: String + let primaryAction: InstallUserAction? + let notices: [String] + let plan: InstallPlanPresentation? + let timeline: InstallTimelinePresentation? + let completion: InstallCompletionPresentation? + + init( + state: DeployWorkflowState, + plan: DeployPlanPayload?, + result: DeployResultPayload?, + error: BackendErrorViewModel?, + events: [BackendEvent], + currentStage: OperationStageState?, + profile: DeviceProfile, + hostWarning: HostCompatibilityWarning? = nil + ) { + self.title = L10n.string("dashboard.tab.install") + self.stateTitle = state.title + self.plan = plan.map { InstallPlanPresentation(plan: $0, profile: profile, hostWarning: hostWarning) } + self.timeline = Self.timeline(for: state, events: events, currentStage: currentStage) + self.completion = result.map(InstallCompletionPresentation.init) + + switch state { + case .idle: + self.statusMessage = L10n.string("install.state.idle") + self.primaryAction = .createPlan + self.notices = [] + case .planning: + self.statusMessage = L10n.string("install.state.planning") + self.primaryAction = nil + self.notices = [] + case .planReady: + self.statusMessage = L10n.string("install.state.plan_ready") + self.primaryAction = plan == nil ? .createPlan : .installUpdate + self.notices = [] + case .planStale: + self.statusMessage = L10n.string("install.state.plan_stale") + self.primaryAction = .regeneratePlan + self.notices = [L10n.string("install.warning.plan_stale")] + case .planFailed: + self.statusMessage = error?.message ?? L10n.string("install.state.plan_failed") + self.primaryAction = .createPlan + self.notices = [] + case .deploying: + self.statusMessage = L10n.string("install.state.deploying") + self.primaryAction = nil + self.notices = [] + case .awaitingConfirmation: + self.statusMessage = L10n.string("install.state.awaiting_confirmation") + self.primaryAction = nil + self.notices = [L10n.string("install.warning.awaiting_confirmation")] + case .deployed: + self.statusMessage = L10n.string("install.state.deployed") + self.primaryAction = nil + self.notices = [] + case .deployFailed: + self.statusMessage = error?.message ?? L10n.string("install.state.deploy_failed") + self.primaryAction = .regeneratePlan + self.notices = [] + } + } + + private static func timeline( + for state: DeployWorkflowState, + events: [BackendEvent], + currentStage: OperationStageState? + ) -> InstallTimelinePresentation? { + switch state { + case .planning, .deploying, .awaitingConfirmation: + return InstallTimelinePresentation(events: events, currentStage: currentStage) + case .idle, .planReady, .planStale, .planFailed, .deployed, .deployFailed: + return nil + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift similarity index 100% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift index 6981ad7d..f88b703d 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -24,6 +24,17 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(AddDeviceEntryMode.allCases, [.discover, .manual]) } + func testInvalidDiscoverTimeoutFailsWithoutRunningHelper() async throws { + let fixture = try await makeStore(responses: []) + fixture.store.bonjourTimeout = "bad" + + fixture.store.runDiscover() + + XCTAssertEqual(fixture.store.state, .failed) + XCTAssertEqual(fixture.store.error?.code, "validation_failed") + XCTAssertEqual(fixture.runner.calls, []) + } + func testDiscoverEmptyReadyAndFailureStates() async throws { let empty = try await makeStore(responses: [ .init(events: [ @@ -141,6 +152,20 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(fixture.store.devices[1].addresses, ["169.254.44.9", "10.0.0.2"]) } + func testMalformedDiscoverPayloadFailsContract() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + + fixture.store.runDiscover() + + try await waitUntilStoreState { fixture.store.state == .failed } + XCTAssertEqual(fixture.store.error?.code, "contract_decode_failed") + XCTAssertEqual(fixture.store.devices, []) + } + func testDiscoveredDeviceModelTextUsesFullModelIdentifier() throws { let payload = try testDiscoveredDevice( syap: "116", @@ -322,6 +347,52 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertNil(fixture.runner.calls[1].params["host"]) } + func testConfigureAuthFailurePreservesDiscoverySelection() async throws { + let record = testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["10.0.0.5"], + fullname: "Office Capsule._airport._tcp.local." + ) + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]), + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "auth_failed", message: "bad password") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + let selectedID = fixture.store.selectedDeviceID + fixture.store.password = "bad" + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .authFailed } + XCTAssertEqual(fixture.store.selectedDeviceID, selectedID) + XCTAssertEqual(fixture.store.devices.count, 1) + XCTAssertEqual(fixture.registry.profiles, []) + } + + func testMalformedConfigurePayloadFailsContractAndSavesNothing() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .failed } + XCTAssertEqual(fixture.store.error?.code, "contract_decode_failed") + XCTAssertEqual(fixture.registry.profiles, []) + XCTAssertNil(fixture.store.savedProfile) + } + func testAuthFailureAndUnsupportedDeviceSaveNothing() async throws { let auth = try await makeStore(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift deleted file mode 100644 index a7806405..00000000 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift +++ /dev/null @@ -1,428 +0,0 @@ -import XCTest -@testable import TimeCapsuleSMBApp - -@MainActor -final class ConnectionWorkflowStoreTests: XCTestCase { - func testStateInventoryIsExplicit() { - XCTAssertEqual(ConnectionWorkflowState.allCases, [ - .idle, - .discovering, - .discoveryReady, - .discoveryEmpty, - .discoveryFailed, - .configuring, - .configured, - .configureFailed - ]) - } - - func testInvalidDiscoverTimeoutMovesToDiscoveryFailedWithoutRunningHelper() { - let runner = WorkflowRecordingRunner(responses: []) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - store.bonjourTimeout = "bad" - - store.runDiscover() - - XCTAssertEqual(store.state, .discoveryFailed) - XCTAssertEqual(store.error?.code, "validation_failed") - XCTAssertEqual(runner.calls, []) - } - - func testDiscoverSingleDeviceAutoSelectsAndRecordsStage() async throws { - let record = deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent(type: "stage", operation: "discover", stage: "bonjour_discovery", risk: "local_read", cancellable: true), - BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [record])) - ]) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - store.bonjourTimeout = "0.25" - - store.runDiscover() - - XCTAssertEqual(store.state, .discovering) - try await waitUntil { store.state == .discoveryReady } - XCTAssertEqual(store.currentStage?.stage, "bonjour_discovery") - XCTAssertEqual(store.devices.count, 1) - XCTAssertEqual(store.devices[0].name, "TC") - XCTAssertEqual(store.devices[0].syap, "119") - XCTAssertEqual(store.selectedDeviceID, store.devices[0].id) - XCTAssertEqual(runner.calls.first?.operation, "discover") - XCTAssertEqual(runner.calls.first?.params["timeout"], .number(0.25)) - } - - func testDiscoverEmptyResultMovesToDiscoveryEmpty() async throws { - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [])) - ]) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - - store.runDiscover() - - try await waitUntil { store.state == .discoveryEmpty } - XCTAssertEqual(store.devices, []) - XCTAssertNil(store.selectedDeviceID) - } - - func testDiscoverMultipleDevicesRequiresExplicitSelection() async throws { - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [ - deviceRecord(name: "TC One", ipv4: ["10.0.0.2"], syap: "119"), - deviceRecord(name: "TC Two", ipv4: ["10.0.0.3"], syap: "120") - ])) - ]) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - - store.runDiscover() - - try await waitUntil { store.state == .discoveryReady } - XCTAssertEqual(store.devices.count, 2) - XCTAssertNil(store.selectedDeviceID) - - store.select(store.devices[1]) - - XCTAssertEqual(store.selectedDeviceID, store.devices[1].id) - XCTAssertEqual(store.selectedDevice?.name, "TC Two") - } - - func testDiscoverBackendErrorMovesToDiscoveryFailedWithRecovery() async throws { - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent( - type: "error", - operation: "discover", - code: "operation_failed", - message: "Bonjour failed.", - recovery: recovery(title: "Discovery failed", actions: ["Retry discovery."]) - ) - ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - - store.runDiscover() - - try await waitUntil { store.state == .discoveryFailed } - XCTAssertEqual(store.error?.message, "Bonjour failed.") - XCTAssertEqual(store.error?.recovery?.title, "Discovery failed") - XCTAssertEqual(store.error?.recovery?.actions, ["Retry discovery."]) - } - - func testMalformedDiscoverPayloadMovesToDiscoveryFailed() async throws { - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent( - type: "result", - operation: "discover", - ok: true, - payload: .object(["schema_version": .string("wrong")]) - ) - ]) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - - store.runDiscover() - - try await waitUntil { store.state == .discoveryFailed } - XCTAssertEqual(store.error?.code, "contract_decode_failed") - } - - func testConfigureRejectsMissingPasswordWithoutRunningHelper() { - let runner = WorkflowRecordingRunner(responses: []) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - store.manualHost = "root@10.0.0.2" - - store.runConfigure(password: " ") - - XCTAssertEqual(store.state, .configureFailed) - XCTAssertEqual(store.error?.code, "validation_failed") - XCTAssertEqual(runner.calls, []) - } - - func testConfigureRejectsMissingTargetWithoutRunningHelper() { - let runner = WorkflowRecordingRunner(responses: []) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - - store.runConfigure(password: "pw") - - XCTAssertEqual(store.state, .configureFailed) - XCTAssertEqual(store.error?.message, "Choose a discovered device or enter a host.") - XCTAssertEqual(runner.calls, []) - } - - func testConfigureSelectedDeviceSendsSelectedRecordAndStoresResult() async throws { - let record = deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [record])) - ]), - .init(events: [ - BackendEvent(type: "stage", operation: "configure", stage: "ssh_probe", risk: "remote_read", cancellable: true), - BackendEvent(type: "result", operation: "configure", ok: true, payload: configurePayload(host: "root@10.0.0.2")) - ]) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - - store.runDiscover() - try await waitUntil { store.state == .discoveryReady } - store.runConfigure(password: "pw") - - XCTAssertEqual(store.state, .configuring) - try await waitUntil { store.state == .configured } - XCTAssertEqual(store.currentStage?.stage, "ssh_probe") - XCTAssertEqual(store.configuredDevice?.host, "root@10.0.0.2") - XCTAssertEqual(store.configuredDevice?.sshAuthenticated, true) - XCTAssertEqual(runner.calls.count, 2) - XCTAssertNil(runner.calls[1].params["host"]) - XCTAssertEqual(runner.calls[1].params["selected_record"], store.devices[0].rawRecord) - XCTAssertEqual(runner.calls[1].params["password"], .string("pw")) - } - - func testConfigureManualHostSendsHostWhenNoDeviceSelected() async throws { - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent(type: "result", operation: "configure", ok: true, payload: configurePayload(host: "root@10.0.0.9")) - ]) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - store.manualHost = " root@10.0.0.9 " - store.debugLogging = true - - store.runConfigure(password: "pw") - - try await waitUntil { store.state == .configured } - XCTAssertEqual(runner.calls.first?.operation, "configure") - XCTAssertEqual(runner.calls.first?.params["host"], .string("root@10.0.0.9")) - XCTAssertNil(runner.calls.first?.params["selected_record"]) - XCTAssertEqual(runner.calls.first?.params["debug_logging"], .bool(true)) - } - - func testConfigureAuthFailurePreservesDiscoverySelectionAndShowsRecovery() async throws { - let record = deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [record])) - ]), - .init(events: [ - BackendEvent( - type: "error", - operation: "configure", - code: "auth_failed", - message: "The AirPort admin password did not work.", - recovery: recovery(title: "AirPort password rejected", actions: ["Re-enter the password."]) - ) - ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - - store.runDiscover() - try await waitUntil { store.state == .discoveryReady } - let selectedID = store.selectedDeviceID - store.runConfigure(password: "bad") - - try await waitUntil { store.state == .configureFailed } - XCTAssertEqual(store.selectedDeviceID, selectedID) - XCTAssertEqual(store.devices.count, 1) - XCTAssertEqual(store.error?.code, "auth_failed") - XCTAssertEqual(store.error?.recovery?.title, "AirPort password rejected") - } - - func testConfigureFalseResultMovesToConfigureFailed() async throws { - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent( - type: "result", - operation: "configure", - ok: false, - payload: .object(["summary": .string("configuration failed.")]) - ) - ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - store.manualHost = "root@10.0.0.2" - - store.runConfigure(password: "pw") - - try await waitUntil { store.state == .configureFailed } - XCTAssertEqual(store.error?.message, "configuration failed.") - } - - func testMalformedConfigurePayloadMovesToConfigureFailed() async throws { - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent( - type: "result", - operation: "configure", - ok: true, - payload: .object(["schema_version": .number(1)]) - ) - ]) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - store.manualHost = "root@10.0.0.2" - - store.runConfigure(password: "pw") - - try await waitUntil { store.state == .configureFailed } - XCTAssertEqual(store.error?.code, "contract_decode_failed") - } - - func testClearReturnsWorkflowToIdle() async throws { - let runner = WorkflowRecordingRunner(responses: [ - .init(events: [ - BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [ - deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") - ])) - ]) - ]) - let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) - - store.runDiscover() - try await waitUntil { store.state == .discoveryReady } - store.clear() - - XCTAssertEqual(store.state, .idle) - XCTAssertEqual(store.devices, []) - XCTAssertNil(store.selectedDeviceID) - XCTAssertNil(store.configuredDevice) - XCTAssertNil(store.error) - XCTAssertEqual(store.events.count, 0) - } - - private func waitUntil( - timeoutNanoseconds: UInt64 = 2_000_000_000, - _ condition: @escaping @MainActor () -> Bool - ) async throws { - let start = DispatchTime.now().uptimeNanoseconds - while !condition() { - if DispatchTime.now().uptimeNanoseconds - start > timeoutNanoseconds { - XCTFail("Timed out waiting for connection workflow state change.") - return - } - try await Task.sleep(nanoseconds: 10_000_000) - } - } - - private func deviceRecord(name: String, ipv4: [String], syap: String) -> JSONValue { - .object([ - "name": .string(name), - "hostname": .string("\(name.lowercased().replacingOccurrences(of: " ", with: "-")).local."), - "service_type": .string("_airport._tcp.local."), - "port": .number(5009), - "ipv4": .array(ipv4.map(JSONValue.string)), - "ipv6": .array([]), - "services": .array([.string("_airport._tcp.local.")]), - "properties": .object(["syAP": .string(syap)]), - "fullname": .string("\(name)._airport._tcp.local.") - ]) - } - - private func discoverPayload(records: [JSONValue]) -> JSONValue { - .object([ - "schema_version": .number(1), - "instances": .array([]), - "resolved": .array(records), - "counts": .object([ - "instances": .number(0), - "resolved": .number(Double(records.count)) - ]), - "summary": .string("discovered \(records.count) resolved AirPort service(s).") - ]) - } - - private func configurePayload(host: String) -> JSONValue { - .object([ - "schema_version": .number(1), - "config_path": .string("/app/.env"), - "host": .string(host), - "configure_id": .string("cfg-1"), - "ssh_authenticated": .bool(true), - "device_syap": .string("119"), - "device_model": .string("Time Capsule"), - "compatibility": .object([ - "payload_family": .string("netbsd6_samba4"), - "supported": .bool(true), - "syap_candidates": .array([.string("119")]), - "model_candidates": .array([.string("Time Capsule")]) - ]), - "device": .object([ - "host": .string(host), - "syap": .string("119"), - "model": .string("Time Capsule") - ]), - "summary": .string("configuration saved and SSH authentication verified.") - ]) - } - - private func recovery(title: String, actions: [String]) -> JSONValue { - .object([ - "title": .string(title), - "message": .string(title), - "actions": .array(actions.map(JSONValue.string)), - "retryable": .bool(true), - "suggested_operation": .string("configure") - ]) - } -} - -private final class WorkflowRecordingRunner: HelperRunning, @unchecked Sendable { - struct Call: Equatable, Sendable { - let helperPath: String? - let operation: String - let params: [String: JSONValue] - let context: DeviceRuntimeContext? - } - - struct Response: Sendable { - let events: [BackendEvent] - let result: HelperRunResult - - init( - events: [BackendEvent], - result: HelperRunResult = HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: "") - ) { - self.events = events - self.result = result - } - } - - private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.WorkflowRecordingRunner") - private var storedResponses: [Response] - private var storedCalls: [Call] = [] - - init(responses: [Response]) { - self.storedResponses = responses - } - - var calls: [Call] { - queue.sync { storedCalls } - } - - func run( - helperPath: String?, - operation: String, - params: [String: JSONValue], - context: DeviceRuntimeContext?, - onEvent: @escaping @Sendable (BackendEvent) async -> Void - ) async -> HelperRunResult { - let response = queue.sync { - storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params, context: context)) - if storedResponses.isEmpty { - return Response( - events: [BackendEvent.error(operation: operation, code: "missing_test_response", message: "No test response queued.")], - result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") - ) - } - return storedResponses.removeFirst() - } - - for event in response.events { - await onEvent(event) - } - return response.result - } -} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift index 8803d42a..b57fbd8d 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -2,24 +2,6 @@ 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"), @@ -34,6 +16,194 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(presentation.groups.first?.domain, "Finder") } + func testOverviewPresentationPromptsForMissingPassword() throws { + var profile = try makeProfile() + profile.passwordState = .missing + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .missing, + displayStatus: .passwordNeeded, + primaryAction: .replacePassword, + hostWarning: nil + ) + + let presentation = DeviceDashboardOverviewPresentation(summary: summary) + let connection = try row(.connection, in: presentation) + + XCTAssertEqual(presentation.primaryAction, .replacePassword) + XCTAssertTrue(presentation.requiresPasswordReplacement) + XCTAssertEqual(connection.status, .warning) + XCTAssertEqual(connection.action, .replacePassword) + } + + func testOverviewPresentationUsesTypedCheckupDomainsForHealthRows() throws { + var profile = try makeProfile() + profile.lastDeploy = DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 100), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "installed" + ) + let checkup = DoctorSummary(payload: try testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "runtime ok", domain: "Runtime"), + testDoctorCheck(status: "WARN", message: "bonjour warning", domain: "Bonjour"), + testDoctorCheck(status: "FAIL", message: "smb failed", domain: "SMB"), + testDoctorCheck(status: "PASS", message: "time machine ok", domain: "Time Machine") + ]).decode(DoctorPayload.self)) + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + ) + + let presentation = DeviceDashboardOverviewPresentation(summary: summary, currentCheckupSummary: checkup) + + XCTAssertEqual(try row(.runtime, in: presentation).status, .good) + XCTAssertEqual(try row(.finderBonjour, in: presentation).status, .warning) + XCTAssertEqual(try row(.smbAuth, in: presentation).status, .failed) + XCTAssertEqual(try row(.timeMachine, in: presentation).status, .good) + } + + func testOverviewPresentationCoversInstallHealthyActivationAndHostWarningStates() throws { + var readyProfile = try makeProfile() + readyProfile.lastCheckup = DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 100), + state: .passed, + passCount: 3, + warnCount: 0, + failCount: 0, + summary: "healthy" + ) + let ready = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: readyProfile, + passwordState: .available, + displayStatus: .readyToInstall, + primaryAction: .installSMB, + hostWarning: nil + )) + XCTAssertEqual(try row(.runtime, in: ready).status, .warning) + XCTAssertEqual(try row(.runtime, in: ready).action, .installUpdate) + + var healthyProfile = readyProfile + healthyProfile.lastDeploy = DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 120), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "installed" + ) + let healthy = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: healthyProfile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + )) + XCTAssertEqual(try row(.runtime, in: healthy).status, .good) + XCTAssertEqual(try row(.runtime, in: healthy).action, .openFinder) + + var netbsd4Profile = try makeProfile(payloadFamily: "netbsd4_samba4") + netbsd4Profile.lastDeploy = healthyProfile.lastDeploy + let activation = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: netbsd4Profile, + passwordState: .available, + displayStatus: .activationNeeded, + primaryAction: .viewCheckup, + hostWarning: nil + )) + XCTAssertEqual(try row(.runtime, in: activation).status, .warning) + XCTAssertEqual(try row(.runtime, in: activation).action, .startSMB) + + let warning = HostCompatibilityWarning(title: "macOS Warning", message: "Time Machine warning.") + let hostWarning = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: healthyProfile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: warning + )) + XCTAssertEqual(try row(.timeMachine, in: hostWarning).status, .warning) + XCTAssertEqual(try row(.timeMachine, in: hostWarning).detail, "Time Machine warning.") + } + + func testInstallPlanPresentationShowsDeviceImpactAndWarnings() throws { + let plan = try netbsd4DeployPlan().decode(DeployPlanPayload.self) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + let warning = HostCompatibilityWarning(title: "macOS Warning", message: "Time Machine warning.") + + let presentation = InstallPlanPresentation(plan: plan, profile: profile, hostWarning: warning) + + XCTAssertEqual(presentation.title, "Install / Update SMB and Start Runtime") + XCTAssertTrue(presentation.sections.contains { section in + section.rows.contains(InstallPlanRow(label: "Remote Actions", value: "1")) + }) + XCTAssertTrue(presentation.sections.contains { section in + section.rows.contains(InstallPlanRow(label: "Expected Downtime", value: "Usually under a minute; the runtime may start without reboot.")) + }) + XCTAssertEqual(presentation.warnings.count, 2) + } + + func testInstallWorkflowPresentationCoversAllDeployStates() throws { + let profile = try makeProfile() + let plan = try testDeployPlanPayload().decode(DeployPlanPayload.self) + let result = try testDeployResultPayload().decode(DeployResultPayload.self) + let error = BackendErrorViewModel(operation: "deploy", code: "operation_failed", message: "failed") + + let cases: [(DeployWorkflowState, DeployPlanPayload?, DeployResultPayload?, BackendErrorViewModel?, InstallUserAction?)] = [ + (.idle, nil, nil, nil, .createPlan), + (.planning, nil, nil, nil, nil), + (.planReady, plan, nil, nil, .installUpdate), + (.planStale, plan, nil, nil, .regeneratePlan), + (.planFailed, nil, nil, error, .createPlan), + (.deploying, plan, nil, nil, nil), + (.awaitingConfirmation, plan, nil, nil, nil), + (.deployed, plan, result, nil, nil), + (.deployFailed, plan, nil, error, .regeneratePlan) + ] + + for testCase in cases { + let presentation = InstallWorkflowPresentation( + state: testCase.0, + plan: testCase.1, + result: testCase.2, + error: testCase.3, + events: [], + currentStage: nil, + profile: profile + ) + XCTAssertEqual(presentation.primaryAction, testCase.4, "Unexpected primary action for \(testCase.0)") + } + } + + func testInstallCompletionPresentationShowsVerificationAndNextActions() throws { + let result = try testDeployResultPayload(payloadFamily: "netbsd4_samba4", verified: true, netbsd4: true) + .decode(DeployResultPayload.self) + + let presentation = InstallCompletionPresentation(result: result) + + XCTAssertEqual(presentation.title, "Install / Update Verified") + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Verified", value: "yes"))) + XCTAssertEqual(presentation.warnings, [ + "NetBSD4 devices may need Start SMB after a later reboot unless the boot hook is patched." + ]) + XCTAssertEqual(presentation.actions, [.openFinder, .runCheckup, .viewDiagnostics]) + } + + func testInstallTimelinePresentationUsesDeployEventsOnly() { + let presentation = InstallTimelinePresentation(events: [ + BackendEvent(type: "stage", operation: "doctor", stage: "run_checks"), + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload", description: "uploading") + ], currentStage: nil) + + XCTAssertEqual(presentation.items.count, 1) + XCTAssertEqual(presentation.items.first?.title, "Uploading") + } + private func netbsd4DeployPlan() -> JSONValue { .object([ "schema_version": .number(1), @@ -52,4 +222,24 @@ final class DashboardPresentationTests: XCTestCase { "summary": .string("deployment dry-run plan generated.") ]) } + + private func makeProfile( + id: String = "device-one", + payloadFamily: String = "netbsd6_samba4" + ) throws -> DeviceProfile { + DeviceProfile.make( + id: id, + configuredDevice: try testConfiguredDevice(payloadFamily: payloadFamily), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + } + + private func row( + _ domain: DashboardHealthDomain, + in presentation: DeviceDashboardOverviewPresentation + ) throws -> DashboardHealthRow { + let section = try XCTUnwrap(presentation.healthSections.first { $0.domain == domain }) + return try XCTUnwrap(section.rows.first) + } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index 642bb48a..f5baa940 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -58,6 +58,91 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(fixture.appStore.dashboardSummary(for: warning).primaryAction, .viewCheckup) } + func testPrimaryActionsRouteThroughDashboardSession() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let opener = RecordingURLOpener() + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore, urlOpener: opener) + + session.performPrimaryAction(.runCheckup, profile: profile) + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(session.selectedTab, .checkup) + + session.performPrimaryAction(.installSMB, 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(session.selectedTab, .install) + + session.performPrimaryAction(.viewCheckup, profile: profile) + XCTAssertEqual(session.selectedTab, .checkup) + + session.replacementPassword = "draft" + session.performPrimaryAction(.replacePassword, profile: profile) + XCTAssertEqual(session.selectedTab, .overview) + XCTAssertEqual(session.replacementPassword, "") + XCTAssertTrue(session.isReplacingPassword) + + session.performPrimaryAction(.openSMB, profile: profile) + XCTAssertEqual(opener.openedURLs.map(\.absoluteString), ["smb://10.0.0.2"]) + } + + func testPasswordReplacementSaveUpdatesPasswordStateAndHidesEditor() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) + session.performPrimaryAction(.replacePassword, profile: profile) + session.replacementPassword = "new-password" + + await session.saveReplacementPassword(for: profile) + + XCTAssertFalse(session.isReplacingPassword) + XCTAssertEqual(session.replacementPassword, "") + XCTAssertNil(session.passwordError) + XCTAssertEqual(try fixture.passwordStore.password(for: profile.keychainAccount), "new-password") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .available) + } + + func testPasswordReplacementSaveFailureKeepsEditorOpen() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + fixture.passwordStore.saveFailure = .save + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) + session.replacementPassword = "new-password" + + await session.saveReplacementPassword(for: profile) + + XCTAssertTrue(session.isReplacingPassword) + XCTAssertEqual(session.passwordError, "In-memory password store save failed.") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) + } + func testDashboardSessionsAreIsolatedByProfile() async throws { let fixture = try await makeFixture(responses: []) let first = try await fixture.registry.saveConfiguredDevice( @@ -426,6 +511,7 @@ final class DashboardStoreTests: XCTestCase { profile: profile )) XCTAssertEqual(session.selectedTab, .overview) + XCTAssertTrue(session.isReplacingPassword) } func testRecoveryRunCheckupAndInstallActionsStartBackendOperations() async throws { @@ -526,6 +612,44 @@ final class DashboardStoreTests: XCTestCase { )) } + func testInstallCompletionActionsRunThroughSession() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let opener = RecordingURLOpener() + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore, urlOpener: opener) + var diagnosticsShown = false + + session.performInstallAction(.openFinder, profile: profile) { + diagnosticsShown = true + } + XCTAssertEqual(opener.openedURLs.map(\.absoluteString), ["smb://10.0.0.2"]) + XCTAssertFalse(diagnosticsShown) + + session.performInstallAction(.runCheckup, profile: profile) { + diagnosticsShown = true + } + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(session.selectedTab, .checkup) + + session.performInstallAction(.viewDiagnostics, profile: profile) { + diagnosticsShown = true + } + XCTAssertTrue(diagnosticsShown) + } + func testForgetProfileDeletesRegistryConfigDirectoryAndPassword() async throws { let fixture = try await makeFixture(responses: []) let profile = try await fixture.registry.saveConfiguredDevice( @@ -569,3 +693,11 @@ final class DashboardStoreTests: XCTestCase { return (appStore, registry, passwordStore, runner) } } + +private final class RecordingURLOpener: URLOpening { + private(set) var openedURLs: [URL] = [] + + func open(_ url: URL) { + openedURLs.append(url) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift deleted file mode 100644 index de463bbe..00000000 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift +++ /dev/null @@ -1,190 +0,0 @@ -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 index 16dec9c5..4c691bcf 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -313,11 +313,15 @@ func testDeployPlanPayload(payloadFamily: String = "netbsd6_samba4") -> JSONValu ]) } -func testDeployResultPayload(payloadFamily: String = "netbsd6_samba4", verified: Bool = true) -> JSONValue { +func testDeployResultPayload( + payloadFamily: String = "netbsd6_samba4", + verified: Bool = true, + netbsd4: Bool = false +) -> JSONValue { .object([ "schema_version": .number(1), "payload_dir": .string("/Volumes/dk2/.samba4"), - "netbsd4": .bool(false), + "netbsd4": .bool(netbsd4), "payload_family": .string(payloadFamily), "requires_reboot": .bool(true), "rebooted": .bool(true), From d7901d1901ab771d349814c4c3510939459bce90 Mon Sep 17 00:00:00 2001 From: James Chang Date: Thu, 21 May 2026 05:52:36 -0700 Subject: [PATCH 024/129] Add presentation-driven checkup and maintenance dashboard UX --- .../Policies/DoctorCheckDomainPolicy.swift | 141 ++++++ .../Resources/en.lproj/Localizable.strings | 63 +++ .../Views/Dashboard/CheckupTab.swift | 203 +++++++-- .../Views/Dashboard/FlashBootHookView.swift | 17 +- .../Views/Dashboard/MaintenanceTab.swift | 385 +++++++++++----- .../Workflows/CheckupPresentation.swift | 223 ++++++++++ .../DashboardOverviewPresentation.swift | 66 ++- .../Workflows/DashboardPresentation.swift | 30 -- .../Workflows/DeviceDashboardSession.swift | 59 +++ .../Workflows/FlashPresentation.swift | 49 ++ .../Workflows/MaintenancePresentation.swift | 420 ++++++++++++++++++ .../DashboardPresentationTests.swift | 222 ++++++++- .../FlashWorkflowStoreTests.swift | 20 + .../MaintenanceStoreTests.swift | 174 ++------ .../StoreTestSupport.swift | 121 +++++ 15 files changed, 1819 insertions(+), 374 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DoctorCheckDomainPolicy.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DoctorCheckDomainPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DoctorCheckDomainPolicy.swift new file mode 100644 index 00000000..b4aeadce --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DoctorCheckDomainPolicy.swift @@ -0,0 +1,141 @@ +import Foundation + +enum DoctorCheckDomain: String, CaseIterable, Equatable, Hashable, Identifiable { + case connection + case runtime + case finderBonjour + case smbAuth + case timeMachine + case disk + case metadata + case general + + var id: String { rawValue } + + var title: String { + switch self { + case .connection: + return L10n.string("doctor.domain.connection") + case .runtime: + return L10n.string("doctor.domain.runtime") + case .finderBonjour: + return L10n.string("doctor.domain.finder_bonjour") + case .smbAuth: + return L10n.string("doctor.domain.smb_auth") + case .timeMachine: + return L10n.string("doctor.domain.time_machine") + case .disk: + return L10n.string("doctor.domain.disk") + case .metadata: + return L10n.string("doctor.domain.metadata") + case .general: + return L10n.string("doctor.domain.general") + } + } +} + +enum DoctorCheckSeverity: Int, Equatable, Comparable { + case failed = 0 + case warning = 1 + case passed = 2 + case unknown = 3 + + static func < (left: DoctorCheckSeverity, right: DoctorCheckSeverity) -> Bool { + left.rawValue < right.rawValue + } +} + +struct DoctorDomainSignal: Equatable { + let domain: DoctorCheckDomain + let checks: [DoctorCheckPayload] + let passCount: Int + let warnCount: Int + let failCount: Int + let infoCount: Int + + var severity: DoctorCheckSeverity { + if failCount > 0 { + return .failed + } + if warnCount > 0 { + return .warning + } + if passCount > 0 { + return .passed + } + return .unknown + } + + var countSummary: String { + L10n.format("dashboard.health.check_counts", passCount, warnCount, failCount) + } +} + +enum DoctorCheckDomainPolicy { + static func domain(for rawDomain: String?) -> DoctorCheckDomain { + let normalized = rawDomain? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + switch normalized { + case "connection", "device", "ssh": + return .connection + case "runtime", "process", "service": + return .runtime + case "bonjour", "finder", "advertising", "discovery": + return .finderBonjour + case "smb", "smb auth", "auth": + return .smbAuth + case "time machine", "timemachine": + return .timeMachine + case "disk", "storage", "volume", "fsck": + return .disk + case "metadata", "xattrs", "xattr", "repair-xattrs": + return .metadata + default: + return .general + } + } + + static func domain(for check: DoctorCheckPayload) -> DoctorCheckDomain { + domain(for: check.details.stringValue(for: "domain")) + } + + static func signals(from summary: DoctorSummary) -> [DoctorDomainSignal] { + let grouped = Dictionary(grouping: summary.groups.flatMap(\.checks), by: domain(for:)) + return grouped + .map { signal(domain: $0.key, checks: $0.value) } + .sorted { left, right in + left.severity == right.severity + ? left.domain.title < right.domain.title + : left.severity < right.severity + } + } + + static func signal(for domain: DoctorCheckDomain, summary: DoctorSummary?) -> DoctorDomainSignal? { + guard let summary else { + return nil + } + let checks = summary.groups + .flatMap(\.checks) + .filter { self.domain(for: $0) == domain } + guard !checks.isEmpty else { + return nil + } + return signal(domain: domain, checks: checks) + } + + private static func signal(domain: DoctorCheckDomain, checks: [DoctorCheckPayload]) -> DoctorDomainSignal { + DoctorDomainSignal( + domain: domain, + checks: checks, + passCount: checks.filter { normalizedStatus($0.status) == "PASS" }.count, + warnCount: checks.filter { normalizedStatus($0.status) == "WARN" }.count, + failCount: checks.filter { normalizedStatus($0.status) == "FAIL" }.count, + infoCount: checks.filter { normalizedStatus($0.status) == "INFO" }.count + ) + } + + private static func normalizedStatus(_ status: String) -> String { + status.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 46fb5ed2..ea90e3cb 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -211,6 +211,14 @@ "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?"; +"doctor.domain.connection" = "Connection"; +"doctor.domain.disk" = "Disk"; +"doctor.domain.finder_bonjour" = "Finder / Bonjour"; +"doctor.domain.general" = "General"; +"doctor.domain.metadata" = "Metadata"; +"doctor.domain.runtime" = "Runtime"; +"doctor.domain.smb_auth" = "SMB Auth"; +"doctor.domain.time_machine" = "Time Machine"; "event.summary.check" = "%@ %@"; "event.summary.check.default_status" = "INFO"; "event.summary.error" = "%@: %@"; @@ -226,6 +234,10 @@ "field.mount_wait" = "Mount wait seconds"; "field.password" = "Password"; "field.repair_xattrs_path" = "Repair xattrs path"; +"flash.action.backup_inspect" = "Back Up and Inspect"; +"flash.action.patch_boot_hook" = "Patch Boot Hook"; +"flash.action.restore_firmware" = "Restore Apple Firmware"; +"flash.title" = "Persistent NetBSD4 Boot Hook"; "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."; @@ -233,6 +245,16 @@ "activity.app_ready" = "App Ready"; "activity.last_operation" = "Last operation"; "activity.no_active_operation" = "No active operation"; +"checkup.advanced_options" = "Advanced Options"; +"checkup.option.skip_bonjour" = "Skip Bonjour"; +"checkup.option.skip_smb" = "Skip SMB"; +"checkup.option.skip_ssh" = "Skip SSH"; +"checkup.status.failed" = "Failed"; +"checkup.status.info" = "Info"; +"checkup.status.passed" = "Passed"; +"checkup.status.unknown" = "Unknown"; +"checkup.status.warning" = "Warning"; +"checkup.timeline.title" = "Progress"; "maintenance.action.choose" = "Choose"; "maintenance.action.choose_folder" = "Choose Folder"; "maintenance.action.find_volumes" = "Find Volumes"; @@ -244,6 +266,7 @@ "maintenance.action.scan_metadata" = "Scan Metadata"; "maintenance.action.start_smb" = "Start SMB"; "maintenance.action.uninstall" = "Uninstall"; +"maintenance.advanced_options" = "Advanced Options"; "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"; @@ -259,6 +282,46 @@ "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.completion.activate" = "Start SMB Complete"; +"maintenance.completion.fsck" = "Disk Repair Complete"; +"maintenance.completion.repair_xattrs" = "Metadata Repair Complete"; +"maintenance.completion.uninstall" = "Uninstall Complete"; +"maintenance.fsck.no_volumes" = "Find mounted volumes before planning disk repair."; +"maintenance.plan.activate" = "Start SMB Plan"; +"maintenance.plan.fsck" = "Disk Repair Plan"; +"maintenance.plan.repair_xattrs" = "Metadata Scan"; +"maintenance.plan.row.actions" = "Actions"; +"maintenance.plan.row.device" = "Device"; +"maintenance.plan.row.findings" = "Findings"; +"maintenance.plan.row.host" = "Host"; +"maintenance.plan.row.mountpoint" = "Mountpoint"; +"maintenance.plan.row.path" = "Path"; +"maintenance.plan.row.payload_dirs" = "Payload Directories"; +"maintenance.plan.row.post_checks" = "Post-checks"; +"maintenance.plan.row.reboot" = "Reboot"; +"maintenance.plan.row.remote_actions" = "Remote Actions"; +"maintenance.plan.row.repairable" = "Repairable"; +"maintenance.plan.row.wait_after_reboot" = "Wait After Reboot"; +"maintenance.plan.uninstall" = "Uninstall Plan"; +"maintenance.result.already_active" = "Already Active"; +"maintenance.result.returncode" = "Return Code"; +"maintenance.state.awaiting_confirmation" = "Review the confirmation dialog before continuing."; +"maintenance.state.failed" = "Maintenance failed."; +"maintenance.state.fsck_list_ready" = "Choose a volume, then plan disk repair."; +"maintenance.state.idle" = "Choose the next maintenance action."; +"maintenance.state.loading" = "Finding mounted volumes."; +"maintenance.state.plan_ready" = "Review the plan before running this maintenance action."; +"maintenance.state.plan_stale" = "Options changed after this plan was created."; +"maintenance.state.planning" = "Creating a maintenance plan."; +"maintenance.state.running" = "Maintenance is running."; +"maintenance.state.scan_ready" = "Review the scan before repairing metadata."; +"maintenance.state.scan_stale" = "The path changed after this scan."; +"maintenance.state.scanning" = "Scanning metadata."; +"maintenance.state.succeeded" = "Maintenance completed."; +"maintenance.timeline.title" = "Progress"; +"maintenance.warning.destructive_fsck" = "Disk repair can modify the selected Time Capsule volume."; +"maintenance.warning.destructive_uninstall" = "Uninstall removes managed SMB files from this Time Capsule."; +"maintenance.warning.local_metadata_repair" = "Metadata repair modifies files under the selected local SMB mount."; "maintenance.repairable_count" = "%d repairable item(s)"; "maintenance.workflow.activate" = "NetBSD4 Activation"; "maintenance.workflow.fsck" = "Disk Repair"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift index 42ad4516..5a170f32 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift @@ -7,48 +7,53 @@ struct CheckupTab: View { var body: some View { let store = session.doctorStore - VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("dashboard.tab.checkup")) - .font(.title2.weight(.semibold)) - HStack { - TextField(L10n.string("field.bonjour_timeout"), text: $session.doctorStore.bonjourTimeout) - .frame(width: 180) - Button { - session.runCheckup(profile: profile) - } label: { - Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") - } - .disabled(store.isRunning || store.bonjourTimeoutValue == nil) - Label(store.state.title, systemImage: "circle") - } - if let stage = store.currentStage { - StageLine(stage: stage) - } - if let summary = store.summary { - let presentation = CheckupPresentation(summary: summary, state: store.state) - Text(presentation.headline) - .font(.headline) - SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) - ForEach(presentation.groups) { group in - VStack(alignment: .leading, spacing: 4) { - Text(group.domain).font(.headline) - ForEach(Array(group.checks.enumerated()), id: \.offset) { _, check in - HStack { - Text(check.status) - .font(.system(.caption, design: .monospaced)) - .frame(width: 44, alignment: .leading) - Text(check.message) - .font(.caption) - } - } + let presentation = CheckupPresentation( + summary: store.summary, + state: store.state, + events: store.events, + currentStage: store.currentStage, + hostWarning: HostCompatibilityPolicy.warning() + ) + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + CheckupHeaderView(presentation: presentation) + + if let warning = presentation.hostWarning { + WarningBanner(warning: warning) + } + + if let action = presentation.primaryAction { + Button { + session.performCheckupAction(action, profile: profile, showDiagnostics: showDiagnostics) + } label: { + Label(action.title, systemImage: action.systemImage) } + .buttonStyle(.borderedProminent) + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) } - } - if let error = store.error { - ErrorRecoveryView(error: error) { action in - handleRecovery(action: action, error: error) + + if !presentation.timeline.isEmpty { + CheckupTimelineView(items: presentation.timeline) + } + + if !presentation.summaryRows.isEmpty { + SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) + } + + ForEach(presentation.domains) { domain in + CheckupDomainView(domain: domain) + } + + CheckupAdvancedOptionsView(store: store) + + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } } } + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -60,3 +65,125 @@ struct CheckupTab: View { _ = session.handleRecoveryAction(action, error: error, profile: profile) } } + +private struct CheckupHeaderView: View { + let presentation: CheckupPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(presentation.title) + .font(.title2.weight(.semibold)) + Spacer() + Text(presentation.stateTitle) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) + } + Text(presentation.headline) + .font(.callout) + .foregroundStyle(.secondary) + } + } +} + +private struct CheckupTimelineView: View { + let items: [OperationTimelineItem] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("checkup.timeline.title")) + .font(.headline) + ForEach(items) { item in + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon(for: item.state)) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.body.weight(.medium)) + if let detail = item.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + private func icon(for state: OperationTimelineItem.State) -> String { + switch state { + case .pending: + return "circle" + case .running: + return "progress.indicator" + case .succeeded: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + } + } +} + +private struct CheckupDomainView: View { + let domain: CheckupDomainPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label(domain.title, systemImage: domain.status.systemImage) + .font(.headline) + Spacer() + Text(domain.countSummary) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(domain.rows) { row in + HStack(alignment: .top, spacing: 8) { + Label(row.status.title, systemImage: row.status.systemImage) + .labelStyle(.iconOnly) + .frame(width: 16) + Text(row.statusText) + .font(.system(.caption, design: .monospaced)) + .frame(width: 44, alignment: .leading) + Text(row.message) + .font(.caption) + } + } + } + .padding(10) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +private struct CheckupAdvancedOptionsView: View { + @ObservedObject var store: DoctorStore + + var body: some View { + DisclosureGroup(L10n.string("checkup.advanced_options")) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("field.bonjour_timeout")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.bonjour_timeout"), text: $store.bonjourTimeout) + .frame(width: 180) + } + GridRow { + Toggle(L10n.string("checkup.option.skip_ssh"), isOn: $store.skipSSH) + Toggle(L10n.string("checkup.option.skip_bonjour"), isOn: $store.skipBonjour) + } + GridRow { + Toggle(L10n.string("checkup.option.skip_smb"), isOn: $store.skipSMB) + EmptyView() + } + } + .padding(.top, 8) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift index 1fbb53e2..04610949 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift @@ -5,28 +5,27 @@ struct FlashBootHookSection: View { @StateObject private var store = FlashWorkflowStore() var body: some View { + let presentation = FlashPresentation(state: store.state, message: store.eligibilityMessage) VStack(alignment: .leading, spacing: 8) { Divider() HStack { VStack(alignment: .leading, spacing: 3) { - Text("Persistent NetBSD4 Boot Hook") + Text(presentation.title) .font(.headline) - Text(store.eligibilityMessage) + Text(presentation.message) .font(.caption) .foregroundStyle(.secondary) } Spacer() - Label(store.state.title, systemImage: "lock") + Label(presentation.stateTitle, 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) + ForEach(presentation.actions) { action in + Button(action.title) {} + .disabled(!presentation.isEnabled(action)) + } } } .onAppear { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift index 8fab02b4..d25c018a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift @@ -8,47 +8,37 @@ struct MaintenanceTab: View { var body: some View { let store = session.maintenanceStore - let presentation = MaintenanceWorkflowPresentation.presentation(for: store.selectedWorkflow) - VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("dashboard.tab.maintenance")) - .font(.title2.weight(.semibold)) - Picker(L10n.string("dashboard.tab.maintenance"), selection: $session.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) + let presentation = MaintenanceDashboardPresentation(store: store, profile: profile) - 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) - } + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(L10n.string("dashboard.tab.maintenance")) + .font(.title2.weight(.semibold)) - HStack { - TextField(L10n.string("field.mount_wait"), text: $session.maintenanceStore.mountWait) - .frame(width: 150) - Toggle(L10n.string("toggle.no_reboot"), isOn: $session.maintenanceStore.noReboot) - Toggle(L10n.string("toggle.no_wait"), isOn: $session.maintenanceStore.noWait) - } + MaintenanceWorkflowCardsView(cards: presentation.cards) { workflow in + session.maintenanceStore.selectedWorkflow = workflow + } - maintenanceControls(store: store) - FlashBootHookSection(profile: profile) + MaintenanceDetailView( + presentation: presentation.detail, + store: store, + performAction: { action in + session.performMaintenanceAction(action, profile: profile, showDiagnostics: showDiagnostics) + }, + chooseRepairPath: { + chooseRepairPath(store: store) + } + ) - if let stage = store.currentStage { - StageLine(stage: stage) - } - if let error = store.error { - ErrorRecoveryView(error: error) { action in - handleRecovery(action: action, error: error) + FlashBootHookSection(profile: profile) + + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } } } + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -60,61 +50,180 @@ struct MaintenanceTab: View { _ = session.handleRecoveryAction(action, error: error, profile: profile) } - @ViewBuilder - private func maintenanceControls(store: MaintenanceStore) -> some View { - switch store.selectedWorkflow { - case .activate: - HStack { - Button(L10n.string("maintenance.action.plan_start_smb")) { - if let password = session.maintenancePassword(for: profile) { - store.planActivation(password: password, profile: profile) + private func chooseRepairPath(store: MaintenanceStore) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = L10n.string("maintenance.action.choose") + if panel.runModal() == .OK, let url = panel.url { + store.repairPath = url.path + } + } +} + +private struct MaintenanceWorkflowCardsView: View { + let cards: [MaintenanceWorkflowCardPresentation] + let select: (MaintenanceWorkflow) -> Void + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 190), spacing: 10)], alignment: .leading, spacing: 10) { + ForEach(cards) { card in + Button { + select(card.workflow) + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(card.title) + .font(.headline) + Spacer() + Image(systemName: card.isSelected ? "checkmark.circle.fill" : "circle") + } + Text(card.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + Text(card.stateTitle) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading) + .padding(10) + .background(card.isSelected ? Color.accentColor.opacity(0.14) : Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) } - Button(L10n.string("maintenance.action.start_smb")) { - if let password = session.maintenancePassword(for: profile) { - store.runActivation(password: password, profile: profile) - } + .buttonStyle(.plain) + } + } + } +} + +private struct MaintenanceDetailView: View { + let presentation: MaintenanceWorkflowDetailPresentation + @ObservedObject var store: MaintenanceStore + let performAction: (MaintenanceUserAction) -> Void + let chooseRepairPath: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text(presentation.title) + .font(.headline) + Text(presentation.subtitle) + .font(.caption) + .foregroundStyle(.secondary) } - .disabled(!store.canRunActivation) - Label(store.activateState.title, systemImage: "circle") + Spacer() + Text(presentation.stateTitle) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) } - case .uninstall: + + Label(presentation.risk, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + Text(presentation.statusMessage) + .font(.callout) + .foregroundStyle(.secondary) + + if presentation.workflow == .repairXattrs { + RepairPathPicker(store: store, chooseRepairPath: chooseRepairPath) + } + + if presentation.workflow == .fsck { + FsckTargetListView(store: store) + } + HStack { - Button(L10n.string("maintenance.action.plan_uninstall")) { - if let password = session.maintenancePassword(for: profile) { - store.planUninstall(password: password, profile: profile) + if let action = presentation.primaryAction { + Button { + performAction(action) + } label: { + Label(action.title, systemImage: action.systemImage) } + .buttonStyle(.borderedProminent) + .disabled(isDisabled(action)) } - Button(L10n.string("maintenance.action.uninstall")) { - if let password = session.maintenancePassword(for: profile) { - store.runUninstall(password: password, profile: profile) + ForEach(presentation.secondaryActions) { action in + Button { + performAction(action) + } label: { + Label(action.title, systemImage: action.systemImage) } + .disabled(isDisabled(action)) } - .disabled(!store.canRunUninstall) - Label(store.uninstallState.title, systemImage: "circle") } - case .fsck: - VStack(alignment: .leading, spacing: 8) { - HStack { - Button(L10n.string("maintenance.action.find_volumes")) { - if let password = session.maintenancePassword(for: profile) { - store.refreshFsckTargets(password: password, profile: profile) - } - } - Button(L10n.string("maintenance.action.plan_disk_repair")) { - if let password = session.maintenancePassword(for: profile) { - store.planFsck(password: password, profile: profile) - } - } - .disabled(!store.canPlanFsck) - Button(L10n.string("maintenance.action.run_disk_repair")) { - if let password = session.maintenancePassword(for: profile) { - store.runFsck(password: password, profile: profile) - } - } - .disabled(!store.canRunFsck) - Label(store.fsckState.title, systemImage: "circle") - } + + if let timeline = presentation.timeline, !timeline.items.isEmpty { + MaintenanceTimelineView(presentation: timeline) + } + + if let plan = presentation.plan { + MaintenancePlanView(presentation: plan) + } + + if let completion = presentation.completion { + MaintenanceCompletionView(presentation: completion) + } + + MaintenanceAdvancedOptionsView(store: store) + } + .padding(10) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private func isDisabled(_ action: MaintenanceUserAction) -> Bool { + if store.isRunning { + return true + } + switch action { + case .runActivation: + return !store.canRunActivation + case .runUninstall: + return !store.canRunUninstall + case .planFsck: + return !store.canPlanFsck + case .runFsck: + return !store.canRunFsck + case .repairMetadata: + return !store.canRepairXattrs + case .planActivation, .planUninstall, .findVolumes, .scanMetadata, .viewDiagnostics: + return false + } + } +} + +private struct RepairPathPicker: View { + @ObservedObject var store: MaintenanceStore + let chooseRepairPath: () -> Void + + var body: some View { + HStack { + TextField(L10n.string("field.repair_xattrs_path"), text: $store.repairPath) + Button { + chooseRepairPath() + } label: { + Label(L10n.string("maintenance.action.choose_folder"), systemImage: "folder") + } + } + } +} + +private struct FsckTargetListView: View { + @ObservedObject var store: MaintenanceStore + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if store.fsckTargets.isEmpty { + Text(L10n.string("maintenance.fsck.no_volumes")) + .font(.caption) + .foregroundStyle(.secondary) + } else { ForEach(store.fsckTargets) { target in Button { store.selectedFsckTargetID = target.id @@ -128,42 +237,98 @@ struct MaintenanceTab: View { .buttonStyle(.plain) } } - case .repairXattrs: - VStack(alignment: .leading, spacing: 8) { - HStack { - TextField(L10n.string("field.repair_xattrs_path"), text: $session.maintenanceStore.repairPath) - Button { - chooseRepairPath(store: store) - } label: { - Label(L10n.string("maintenance.action.choose_folder"), systemImage: "folder") - } - } - HStack { - Button(L10n.string("maintenance.action.scan_metadata")) { - store.scanRepairXattrs() - } - Button(L10n.string("maintenance.action.repair_metadata")) { - store.runRepairXattrs() + } + } +} + +private struct MaintenancePlanView: View { + let presentation: MaintenancePlanPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(presentation.title) + .font(.headline) + SummaryGrid(rows: presentation.rows.map { ($0.label, $0.value) }) + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + } + } +} + +private struct MaintenanceCompletionView: View { + let presentation: MaintenanceCompletionPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(presentation.title) + .font(.headline) + SummaryGrid(rows: presentation.rows.map { ($0.label, $0.value) }) + } + } +} + +private struct MaintenanceTimelineView: View { + let presentation: MaintenanceTimelinePresentation + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("maintenance.timeline.title")) + .font(.headline) + ForEach(presentation.items) { item in + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon(for: item.state)) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.body.weight(.medium)) + if let detail = item.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } } - .disabled(!store.canRepairXattrs) - Label(store.repairState.title, systemImage: "circle") - } - if let scan = store.repairScan { - Text(L10n.format("maintenance.repairable_count", scan.repairableCount)) - .foregroundStyle(.secondary) } } } } - private func chooseRepairPath(store: MaintenanceStore) { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.prompt = L10n.string("maintenance.action.choose") - if panel.runModal() == .OK, let url = panel.url { - store.repairPath = url.path + private func icon(for state: OperationTimelineItem.State) -> String { + switch state { + case .pending: + return "circle" + case .running: + return "progress.indicator" + case .succeeded: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + } + } +} + +private struct MaintenanceAdvancedOptionsView: View { + @ObservedObject var store: MaintenanceStore + + var body: some View { + DisclosureGroup(L10n.string("maintenance.advanced_options")) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.mountWait) + .frame(width: 150) + } + GridRow { + Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + } + } + .padding(.top, 8) } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift new file mode 100644 index 00000000..d597e422 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift @@ -0,0 +1,223 @@ +import Foundation + +enum CheckupUserAction: String, Equatable, Identifiable { + case runCheckup + case installUpdate + case startSMB + case replacePassword + case openFinder + case viewDiagnostics + + var id: String { rawValue } + + var title: String { + switch self { + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .installUpdate: + return L10n.string("dashboard.action.install_update_smb") + case .startSMB: + return L10n.string("dashboard.action.start_smb") + case .replacePassword: + return L10n.string("dashboard.action.replace_password") + case .openFinder: + return L10n.string("dashboard.action.open_finder") + case .viewDiagnostics: + return L10n.string("recovery.action.open_diagnostics") + } + } + + var systemImage: String { + switch self { + case .runCheckup: + return "stethoscope" + case .installUpdate: + return "square.and.arrow.up" + case .startSMB: + return "play.circle" + case .replacePassword: + return "key" + case .openFinder: + return "folder" + case .viewDiagnostics: + return "wrench.and.screwdriver" + } + } +} + +enum CheckupStatusPresentation: String, Equatable { + case passed + case warning + case failed + case info + case unknown + + init(status: String) { + switch status.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { + case "PASS": + self = .passed + case "WARN": + self = .warning + case "FAIL": + self = .failed + case "INFO": + self = .info + default: + self = .unknown + } + } + + init(severity: DoctorCheckSeverity) { + switch severity { + case .failed: + self = .failed + case .warning: + self = .warning + case .passed: + self = .passed + case .unknown: + self = .unknown + } + } + + var title: String { + switch self { + case .passed: + return L10n.string("checkup.status.passed") + case .warning: + return L10n.string("checkup.status.warning") + case .failed: + return L10n.string("checkup.status.failed") + case .info: + return L10n.string("checkup.status.info") + case .unknown: + return L10n.string("checkup.status.unknown") + } + } + + var systemImage: String { + switch self { + case .passed: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + case .info: + return "info.circle" + case .unknown: + return "questionmark.circle" + } + } +} + +struct CheckupRowPresentation: Equatable, Identifiable { + let id: String + let status: CheckupStatusPresentation + let statusText: String + let message: String + + init(index: Int, check: DoctorCheckPayload) { + self.id = "\(index):\(check.status):\(check.message)" + self.status = CheckupStatusPresentation(status: check.status) + self.statusText = check.status + self.message = check.message + } +} + +struct CheckupDomainPresentation: Equatable, Identifiable { + let domain: DoctorCheckDomain + let status: CheckupStatusPresentation + let countSummary: String + let rows: [CheckupRowPresentation] + + var id: String { domain.rawValue } + var title: String { domain.title } + + init(signal: DoctorDomainSignal) { + self.domain = signal.domain + self.status = CheckupStatusPresentation(severity: signal.severity) + self.countSummary = signal.countSummary + self.rows = signal.checks.enumerated().map { CheckupRowPresentation(index: $0.offset, check: $0.element) } + } +} + +struct CheckupPresentation: Equatable { + let title: String + let stateTitle: String + let headline: String + let primaryAction: CheckupUserAction? + let summaryRows: [PresentationRow] + let domains: [CheckupDomainPresentation] + let timeline: [OperationTimelineItem] + let hostWarning: HostCompatibilityWarning? + + init( + summary: DoctorSummary?, + state: DoctorWorkflowState, + events: [BackendEvent] = [], + currentStage: OperationStageState? = nil, + hostWarning: HostCompatibilityWarning? = nil + ) { + self.title = L10n.string("dashboard.tab.checkup") + self.stateTitle = state.title + self.headline = Self.headline(for: state) + self.primaryAction = state == .running ? nil : .runCheckup + self.summaryRows = summary.map(Self.summaryRows) ?? [] + self.domains = summary.map { DoctorCheckDomainPolicy.signals(from: $0).map(CheckupDomainPresentation.init) } ?? [] + self.timeline = Self.timeline(events: events, currentStage: currentStage, state: state) + self.hostWarning = hostWarning + } + + private static func headline(for state: DoctorWorkflowState) -> String { + switch state { + case .passed: + return L10n.string("checkup.presentation.headline.passed") + case .warning: + return L10n.string("checkup.presentation.headline.warning") + case .failed: + return L10n.string("checkup.presentation.headline.failed") + case .runFailed: + return L10n.string("checkup.presentation.headline.run_failed") + case .idle: + return L10n.string("checkup.presentation.headline.idle") + case .running: + return L10n.string("checkup.presentation.headline.running") + } + } + + private static func summaryRows(_ summary: DoctorSummary) -> [PresentationRow] { + [ + PresentationRow(label: L10n.string("checkup.presentation.row.pass"), value: "\(summary.passCount)"), + PresentationRow(label: L10n.string("checkup.presentation.row.warning"), value: "\(summary.warnCount)"), + PresentationRow(label: L10n.string("checkup.presentation.row.fail"), value: "\(summary.failCount)"), + PresentationRow(label: L10n.string("checkup.presentation.row.info"), value: "\(summary.infoCount)") + ] + } + + private static func timeline( + events: [BackendEvent], + currentStage: OperationStageState?, + state: DoctorWorkflowState + ) -> [OperationTimelineItem] { + guard state == .running else { + return [] + } + var items = OperationTimelineBuilder.timeline(from: events) + .filter { $0.operation == "doctor" } + if items.isEmpty, let currentStage { + items = [ + OperationTimelineItem( + id: "current:\(currentStage.operation):\(currentStage.stage)", + operation: currentStage.operation, + title: OperationTimelineBuilder.operationTitle(currentStage.operation), + detail: currentStage.description, + state: .running, + risk: currentStage.risk, + cancellable: currentStage.cancellable + ) + ] + } + return items + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift index 4ef8b54d..9da841fe 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift @@ -103,18 +103,18 @@ enum DashboardHealthDomain: String, CaseIterable, Equatable, Identifiable { } } - fileprivate var checkupDomains: Set { + fileprivate var doctorDomain: DoctorCheckDomain { switch self { case .connection: - return ["connection", "device", "ssh"] + return .connection case .runtime: - return ["runtime", "process", "service"] + return .runtime case .finderBonjour: - return ["bonjour", "finder", "advertising"] + return .finderBonjour case .smbAuth: - return ["smb", "smb auth", "auth"] + return .smbAuth case .timeMachine: - return ["time machine", "timemachine"] + return .timeMachine } } } @@ -330,9 +330,9 @@ struct DeviceDashboardOverviewPresentation: Equatable { return DashboardHealthRow( id: "runtime-checkup", title: DashboardHealthDomain.runtime.title, - detail: signal.detail, - status: signal.status, - action: signal.status == .good ? nil : .viewCheckup + detail: signal.countSummary, + status: dashboardStatus(signal.severity), + action: dashboardStatus(signal.severity) == .good ? nil : .viewCheckup ) } guard let lastDeploy = summary.profile.lastDeploy else { @@ -368,12 +368,13 @@ struct DeviceDashboardOverviewPresentation: Equatable { currentCheckupSummary: DoctorSummary? ) -> DashboardHealthRow { if let signal = checkupSignal(for: domain, summary: currentCheckupSummary) { + let status = dashboardStatus(signal.severity) return DashboardHealthRow( id: "\(domain.rawValue)-current-checkup", title: domain.title, - detail: signal.detail, - status: signal.status, - action: signal.status == .good ? nil : .viewCheckup + detail: signal.countSummary, + status: status, + action: status == .good ? nil : .viewCheckup ) } guard let lastCheckup = summary.profile.lastCheckup else { @@ -412,34 +413,21 @@ struct DeviceDashboardOverviewPresentation: Equatable { private static func checkupSignal( for domain: DashboardHealthDomain, summary: DoctorSummary? - ) -> (status: DashboardHealthStatus, detail: String)? { - guard let summary else { - return nil - } - let groups = summary.groups.filter { group in - domain.checkupDomains.contains(group.domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) - } - guard !groups.isEmpty else { - return nil - } - let checks = groups.flatMap(\.checks) - let passCount = checks.filter { $0.status == "PASS" }.count - let warnCount = checks.filter { $0.status == "WARN" }.count - let failCount = checks.filter { $0.status == "FAIL" }.count - let status: DashboardHealthStatus - if failCount > 0 { - status = .failed - } else if warnCount > 0 { - status = .warning - } else if passCount > 0 { - status = .good - } else { - status = .unknown + ) -> DoctorDomainSignal? { + DoctorCheckDomainPolicy.signal(for: domain.doctorDomain, summary: summary) + } + + private static func dashboardStatus(_ severity: DoctorCheckSeverity) -> DashboardHealthStatus { + switch severity { + case .failed: + return .failed + case .warning: + return .warning + case .passed: + return .good + case .unknown: + return .unknown } - return ( - status: status, - detail: L10n.format("dashboard.health.check_counts", passCount, warnCount, failCount) - ) } private static func snapshotStatus(_ snapshot: DeviceCheckupSnapshot) -> DashboardHealthStatus { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift index d1d83fb8..944856b9 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift @@ -55,36 +55,6 @@ struct PresentationRow: Equatable, Identifiable { let value: String } -struct CheckupPresentation: Equatable { - let headline: String - let summaryRows: [PresentationRow] - let groups: [DoctorCheckGroup] - - init(summary: DoctorSummary, state: DoctorWorkflowState) { - switch state { - case .passed: - self.headline = L10n.string("checkup.presentation.headline.passed") - case .warning: - self.headline = L10n.string("checkup.presentation.headline.warning") - case .failed: - self.headline = L10n.string("checkup.presentation.headline.failed") - case .runFailed: - self.headline = L10n.string("checkup.presentation.headline.run_failed") - case .idle: - self.headline = L10n.string("checkup.presentation.headline.idle") - case .running: - self.headline = L10n.string("checkup.presentation.headline.running") - } - self.summaryRows = [ - PresentationRow(label: L10n.string("checkup.presentation.row.pass"), value: "\(summary.passCount)"), - PresentationRow(label: L10n.string("checkup.presentation.row.warning"), value: "\(summary.warnCount)"), - PresentationRow(label: L10n.string("checkup.presentation.row.fail"), value: "\(summary.failCount)"), - PresentationRow(label: L10n.string("checkup.presentation.row.info"), value: "\(summary.infoCount)") - ] - self.groups = summary.groups - } -} - struct MaintenanceWorkflowPresentation: Equatable { let title: String let subtitle: String diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index 27b9b81d..9d985ae9 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -92,6 +92,65 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } } + func performCheckupAction(_ action: CheckupUserAction, profile: DeviceProfile, showDiagnostics: () -> Void) { + switch action { + case .runCheckup: + runCheckup(profile: profile) + case .installUpdate: + runInstallPlan(profile: profile) + case .startSMB: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .activate + case .replacePassword: + showPasswordReplacement() + case .openFinder: + openSMBAddress(for: profile) + case .viewDiagnostics: + showDiagnostics() + } + } + + func performMaintenanceAction(_ action: MaintenanceUserAction, profile: DeviceProfile, showDiagnostics: () -> Void) { + switch action { + case .planActivation: + if let password = maintenancePassword(for: profile) { + maintenanceStore.planActivation(password: password, profile: profile) + } + case .runActivation: + if let password = maintenancePassword(for: profile) { + maintenanceStore.runActivation(password: password, profile: profile) + } + case .planUninstall: + if let password = maintenancePassword(for: profile) { + maintenanceStore.planUninstall(password: password, profile: profile) + } + case .runUninstall: + if let password = maintenancePassword(for: profile) { + maintenanceStore.runUninstall(password: password, profile: profile) + } + case .findVolumes: + if let password = maintenancePassword(for: profile) { + maintenanceStore.refreshFsckTargets(password: password, profile: profile) + } + case .planFsck: + if let password = maintenancePassword(for: profile) { + maintenanceStore.planFsck(password: password, profile: profile) + } + case .runFsck: + if let password = maintenancePassword(for: profile) { + maintenanceStore.runFsck(password: password, profile: profile) + } + case .scanMetadata: + selectedTab = .maintenance + maintenanceStore.scanRepairXattrs() + case .repairMetadata: + selectedTab = .maintenance + maintenanceStore.runRepairXattrs() + case .viewDiagnostics: + showDiagnostics() + } + } + func saveReplacementPassword(for profile: DeviceProfile) async { let password = replacementPassword guard !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift new file mode 100644 index 00000000..5e466889 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift @@ -0,0 +1,49 @@ +import Foundation + +enum FlashUserAction: String, Equatable, Identifiable { + case backupAndInspect + case patchBootHook + case restoreFirmware + + var id: String { rawValue } + + var title: String { + switch self { + case .backupAndInspect: + return L10n.string("flash.action.backup_inspect") + case .patchBootHook: + return L10n.string("flash.action.patch_boot_hook") + case .restoreFirmware: + return L10n.string("flash.action.restore_firmware") + } + } +} + +struct FlashPresentation: Equatable { + let title: String + let message: String + let stateTitle: String + let actions: [FlashUserAction] + let enabledActions: Set + + init(state: FlashWorkflowState, message: String) { + self.title = L10n.string("flash.title") + self.message = message + self.stateTitle = state.title + self.actions = [.backupAndInspect, .patchBootHook, .restoreFirmware] + switch state { + case .eligibleForReadOnlyAnalysis, .planAvailable: + self.enabledActions = [.backupAndInspect] + case .writeLocked, .awaitingStrongConfirmation: + self.enabledActions = [.backupAndInspect] + case .writing, .readbackValidating, .writeValidated, .manualPowerCycleRequired, .restoreRebooting: + self.enabledActions = [] + case .unavailable, .disabledInThisBuild, .readingBanks, .savingBackup, .analyzingBanks, .failed: + self.enabledActions = [] + } + } + + func isEnabled(_ action: FlashUserAction) -> Bool { + enabledActions.contains(action) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift new file mode 100644 index 00000000..5d5147c4 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift @@ -0,0 +1,420 @@ +import Foundation + +enum MaintenanceUserAction: String, Equatable, Identifiable { + case planActivation + case runActivation + case planUninstall + case runUninstall + case findVolumes + case planFsck + case runFsck + case scanMetadata + case repairMetadata + case viewDiagnostics + + var id: String { rawValue } + + var title: String { + switch self { + case .planActivation: + return L10n.string("maintenance.action.plan_start_smb") + case .runActivation: + return L10n.string("maintenance.action.start_smb") + case .planUninstall: + return L10n.string("maintenance.action.plan_uninstall") + case .runUninstall: + return L10n.string("maintenance.action.uninstall") + case .findVolumes: + return L10n.string("maintenance.action.find_volumes") + case .planFsck: + return L10n.string("maintenance.action.plan_disk_repair") + case .runFsck: + return L10n.string("maintenance.action.run_disk_repair") + case .scanMetadata: + return L10n.string("maintenance.action.scan_metadata") + case .repairMetadata: + return L10n.string("maintenance.action.repair_metadata") + case .viewDiagnostics: + return L10n.string("recovery.action.open_diagnostics") + } + } + + var systemImage: String { + switch self { + case .planActivation, .planUninstall, .planFsck: + return "doc.text.magnifyingglass" + case .runActivation: + return "play.circle" + case .runUninstall: + return "trash" + case .findVolumes: + return "externaldrive" + case .runFsck: + return "externaldrive.badge.exclamationmark" + case .scanMetadata: + return "magnifyingglass" + case .repairMetadata: + return "tag" + case .viewDiagnostics: + return "wrench.and.screwdriver" + } + } +} + +struct MaintenanceWorkflowCardPresentation: Equatable, Identifiable { + let workflow: MaintenanceWorkflow + let title: String + let subtitle: String + let stateTitle: String + let isSelected: Bool + + var id: MaintenanceWorkflow.ID { workflow.id } +} + +struct MaintenancePlanPresentation: Equatable { + let title: String + let rows: [PresentationRow] + let warnings: [String] +} + +struct MaintenanceCompletionPresentation: Equatable { + let title: String + let rows: [PresentationRow] +} + +struct MaintenanceTimelinePresentation: Equatable { + let items: [OperationTimelineItem] + + init(events: [BackendEvent], currentStage: OperationStageState?, workflow: MaintenanceWorkflow) { + let operation = workflow.operationName + var items = OperationTimelineBuilder.timeline(from: events) + .filter { $0.operation == operation } + if items.isEmpty, let currentStage, currentStage.operation == operation { + items = [ + OperationTimelineItem( + id: "current:\(currentStage.operation):\(currentStage.stage)", + operation: currentStage.operation, + title: currentStage.stage, + detail: currentStage.description, + state: .running, + risk: currentStage.risk, + cancellable: currentStage.cancellable + ) + ] + } + self.items = items + } +} + +struct MaintenanceActionContext: Equatable { + let workflow: MaintenanceWorkflow + let state: MaintenanceOperationState + let hasSelectedFsckTarget: Bool + let canRepairXattrs: Bool +} + +enum MaintenanceActionPolicy { + static func primaryAction(for context: MaintenanceActionContext) -> MaintenanceUserAction? { + switch context.workflow { + case .activate: + switch context.state { + case .idle, .failed, .succeeded: + return .planActivation + case .planReady: + return .runActivation + default: + return nil + } + case .uninstall: + switch context.state { + case .idle, .failed, .succeeded, .planStale: + return .planUninstall + case .planReady: + return .runUninstall + default: + return nil + } + case .fsck: + switch context.state { + case .idle, .failed, .succeeded: + return .findVolumes + case .listReady: + return context.hasSelectedFsckTarget ? .planFsck : nil + case .planStale: + return .planFsck + case .planReady: + return .runFsck + default: + return nil + } + case .repairXattrs: + switch context.state { + case .idle, .failed, .repaired, .scanStale: + return .scanMetadata + case .scanReady: + return context.canRepairXattrs ? .repairMetadata : .scanMetadata + default: + return nil + } + } + } + + static func secondaryActions(workflow: MaintenanceWorkflow, state: MaintenanceOperationState) -> [MaintenanceUserAction] { + switch workflow { + case .activate: + return state == .planReady ? [.planActivation] : [] + case .uninstall: + return state == .planReady ? [.planUninstall] : [] + case .fsck: + return state == .planReady ? [.planFsck, .findVolumes] : state == .listReady ? [.findVolumes] : [] + case .repairXattrs: + return state == .scanReady ? [.scanMetadata] : [] + } + } +} + +extension MaintenanceOperationState { + func maintenanceStatusMessage(for workflow: MaintenanceWorkflow) -> String { + switch (workflow, self) { + case (_, .idle): + return L10n.string("maintenance.state.idle") + case (_, .loading): + return L10n.string("maintenance.state.loading") + case (.fsck, .listReady): + return L10n.string("maintenance.state.fsck_list_ready") + case (_, .planning): + return L10n.string("maintenance.state.planning") + case (_, .planReady): + return L10n.string("maintenance.state.plan_ready") + case (_, .planStale): + return L10n.string("maintenance.state.plan_stale") + case (.repairXattrs, .scanning): + return L10n.string("maintenance.state.scanning") + case (.repairXattrs, .scanReady): + return L10n.string("maintenance.state.scan_ready") + case (.repairXattrs, .scanStale): + return L10n.string("maintenance.state.scan_stale") + case (_, .awaitingConfirmation): + return L10n.string("maintenance.state.awaiting_confirmation") + case (_, .running), (_, .repairing): + return L10n.string("maintenance.state.running") + case (_, .succeeded), (_, .repaired): + return L10n.string("maintenance.state.succeeded") + case (_, .failed): + return L10n.string("maintenance.state.failed") + default: + return title + } + } +} + +struct MaintenanceWorkflowDetailPresentation: Equatable { + let workflow: MaintenanceWorkflow + let title: String + let subtitle: String + let risk: String + let stateTitle: String + let statusMessage: String + let primaryAction: MaintenanceUserAction? + let secondaryActions: [MaintenanceUserAction] + let plan: MaintenancePlanPresentation? + let completion: MaintenanceCompletionPresentation? + let timeline: MaintenanceTimelinePresentation? + + @MainActor + init(store: MaintenanceStore, profile: DeviceProfile) { + let workflow = store.selectedWorkflow + let legacy = MaintenanceWorkflowPresentation.presentation(for: workflow) + let state = store.state(for: workflow) + self.workflow = workflow + self.title = legacy.title + self.subtitle = legacy.subtitle + self.risk = legacy.risk + self.stateTitle = state.title + self.statusMessage = state.maintenanceStatusMessage(for: workflow) + self.primaryAction = MaintenanceActionPolicy.primaryAction(for: MaintenanceActionContext( + workflow: workflow, + state: state, + hasSelectedFsckTarget: store.selectedFsckTarget != nil, + canRepairXattrs: store.canRepairXattrs + )) + self.secondaryActions = MaintenanceActionPolicy.secondaryActions(workflow: workflow, state: state) + self.plan = Self.plan(workflow: workflow, store: store, profile: profile) + self.completion = Self.completion(workflow: workflow, store: store) + self.timeline = Self.timeline(workflow: workflow, state: state, store: store) + } + + @MainActor + private static func plan( + workflow: MaintenanceWorkflow, + store: MaintenanceStore, + profile: DeviceProfile + ) -> MaintenancePlanPresentation? { + switch workflow { + case .activate: + guard let plan = store.activationPlan else { return nil } + return MaintenancePlanPresentation( + title: L10n.string("maintenance.plan.activate"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.device"), value: profile.title), + PresentationRow(label: L10n.string("maintenance.plan.row.actions"), value: "\(plan.actions.count)"), + PresentationRow(label: L10n.string("maintenance.plan.row.post_checks"), value: "\(plan.postActivationChecks.count)") + ], + warnings: [] + ) + case .uninstall: + guard let plan = store.uninstallPlan else { return nil } + return MaintenancePlanPresentation( + title: L10n.string("maintenance.plan.uninstall"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.host"), value: plan.host), + PresentationRow(label: L10n.string("maintenance.plan.row.payload_dirs"), value: "\(plan.payloadDirs.count)"), + PresentationRow(label: L10n.string("maintenance.plan.row.remote_actions"), value: "\(plan.remoteActions.count)"), + PresentationRow(label: L10n.string("maintenance.plan.row.reboot"), value: plan.requiresReboot ? L10n.string("value.required") : L10n.string("value.not_required")), + PresentationRow(label: L10n.string("maintenance.plan.row.post_checks"), value: "\(plan.postUninstallChecks.count)") + ], + warnings: [L10n.string("maintenance.warning.destructive_uninstall")] + ) + case .fsck: + guard let plan = store.fsckPlan else { return nil } + return MaintenancePlanPresentation( + title: L10n.string("maintenance.plan.fsck"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.device"), value: plan.device), + PresentationRow(label: L10n.string("maintenance.plan.row.mountpoint"), value: plan.mountpoint), + PresentationRow(label: L10n.string("maintenance.plan.row.reboot"), value: plan.rebootRequired ? L10n.string("value.required") : L10n.string("value.not_required")), + PresentationRow(label: L10n.string("maintenance.plan.row.wait_after_reboot"), value: plan.waitAfterReboot ? L10n.string("value.yes") : L10n.string("value.no")) + ], + warnings: [L10n.string("maintenance.warning.destructive_fsck")] + ) + case .repairXattrs: + guard let scan = store.repairScan else { return nil } + return MaintenancePlanPresentation( + title: L10n.string("maintenance.plan.repair_xattrs"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.path"), value: scan.root ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("maintenance.plan.row.findings"), value: "\(scan.findingCount)"), + PresentationRow(label: L10n.string("maintenance.plan.row.repairable"), value: "\(scan.repairableCount)") + ], + warnings: scan.repairableCount > 0 ? [L10n.string("maintenance.warning.local_metadata_repair")] : [] + ) + } + } + + @MainActor + private static func completion( + workflow: MaintenanceWorkflow, + store: MaintenanceStore + ) -> MaintenanceCompletionPresentation? { + switch workflow { + case .activate: + guard let result = store.activationResult else { return nil } + return MaintenanceCompletionPresentation( + title: L10n.string("maintenance.completion.activate"), + rows: [ + PresentationRow(label: L10n.string("maintenance.result.already_active"), value: result.alreadyActive ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.message"), value: result.message ?? result.summary) + ] + ) + case .uninstall: + guard let result = store.uninstallResult else { return nil } + return MaintenanceCompletionPresentation(title: L10n.string("maintenance.completion.uninstall"), rows: resultRows(result)) + case .fsck: + guard let result = store.fsckResult else { return nil } + return MaintenanceCompletionPresentation( + title: L10n.string("maintenance.completion.fsck"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.device"), value: result.device), + PresentationRow(label: L10n.string("maintenance.result.returncode"), value: result.returncode.map(String.init) ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("deploy.result.verified"), value: result.verified == true ? L10n.string("value.yes") : L10n.string("value.no")) + ] + ) + case .repairXattrs: + guard let result = store.repairResult else { return nil } + return MaintenanceCompletionPresentation( + title: L10n.string("maintenance.completion.repair_xattrs"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.findings"), value: "\(result.findingCount)"), + PresentationRow(label: L10n.string("maintenance.plan.row.repairable"), value: "\(result.repairableCount)"), + PresentationRow(label: L10n.string("maintenance.result.returncode"), value: result.returncode.map(String.init) ?? L10n.string("value.unknown")) + ] + ) + } + } + + private static func resultRows(_ result: MaintenanceResultPayload) -> [PresentationRow] { + [ + PresentationRow(label: L10n.string("deploy.result.reboot_requested"), value: result.rebootRequested == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.verified"), value: result.verified == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.message"), value: result.message ?? result.summary) + ] + } + + @MainActor + private static func timeline( + workflow: MaintenanceWorkflow, + state: MaintenanceOperationState, + store: MaintenanceStore + ) -> MaintenanceTimelinePresentation? { + switch state { + case .loading, .planning, .scanning, .awaitingConfirmation, .running, .repairing: + return MaintenanceTimelinePresentation( + events: store.events, + currentStage: store.currentStage, + workflow: workflow + ) + default: + return nil + } + } +} + +struct MaintenanceDashboardPresentation: Equatable { + let cards: [MaintenanceWorkflowCardPresentation] + let detail: MaintenanceWorkflowDetailPresentation + + @MainActor + init(store: MaintenanceStore, profile: DeviceProfile) { + self.cards = MaintenanceWorkflow.allCases.map { workflow in + let legacy = MaintenanceWorkflowPresentation.presentation(for: workflow) + return MaintenanceWorkflowCardPresentation( + workflow: workflow, + title: legacy.title, + subtitle: legacy.subtitle, + stateTitle: store.state(for: workflow).title, + isSelected: workflow == store.selectedWorkflow + ) + } + self.detail = MaintenanceWorkflowDetailPresentation(store: store, profile: profile) + } +} + +extension MaintenanceWorkflow { + var operationName: String { + switch self { + case .activate: + return "activate" + case .uninstall: + return "uninstall" + case .fsck: + return "fsck" + case .repairXattrs: + return "repair-xattrs" + } + } +} + +extension MaintenanceStore { + func state(for workflow: MaintenanceWorkflow) -> MaintenanceOperationState { + switch workflow { + case .activate: + return activateState + case .uninstall: + return uninstallState + case .fsck: + return fsckState + case .repairXattrs: + return repairState + } + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift index b57fbd8d..fc176535 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import TimeCapsuleSMBApp +@MainActor final class DashboardPresentationTests: XCTestCase { func testCheckupPresentationHeadlineFollowsState() throws { let payload = try testDoctorPayload(checks: [ @@ -13,7 +14,73 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(presentation.headline, "Checkup found warnings.") XCTAssertEqual(presentation.summaryRows.first, PresentationRow(label: "Pass", value: "1")) - XCTAssertEqual(presentation.groups.first?.domain, "Finder") + XCTAssertEqual(presentation.domains.first?.domain, .finderBonjour) + XCTAssertEqual(presentation.domains.first?.status, .warning) + } + + func testDoctorDomainPolicyUsesTypedDetailsDomainAndSeverity() throws { + let payload = try testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ssh ok", domain: "Device"), + testDoctorCheck(status: "WARN", message: "bonjour warning", domain: "Bonjour"), + testDoctorCheck(status: "FAIL", message: "smb failed", domain: "SMB"), + doctorCheckWithoutDomain(status: "INFO", message: "misc info") + ]).decode(DoctorPayload.self) + let summary = DoctorSummary(payload: payload) + + let signals = DoctorCheckDomainPolicy.signals(from: summary) + + XCTAssertEqual(signals.map(\.domain), [.smbAuth, .finderBonjour, .connection, .general]) + XCTAssertEqual(signals.first?.severity, .failed) + XCTAssertEqual(DoctorCheckDomainPolicy.signal(for: .connection, summary: summary)?.passCount, 1) + XCTAssertEqual(DoctorCheckDomainPolicy.signal(for: .general, summary: summary)?.infoCount, 1) + XCTAssertNil(DoctorCheckDomainPolicy.signal(for: .disk, summary: summary)) + + let lowerStatusSummary = DoctorSummary(payload: try testDoctorPayload(checks: [ + testDoctorCheck(status: " warn ", message: "disk warning", domain: "Disk") + ]).decode(DoctorPayload.self)) + XCTAssertEqual(DoctorCheckDomainPolicy.signal(for: .disk, summary: lowerStatusSummary)?.warnCount, 1) + XCTAssertEqual(CheckupStatusPresentation(status: " warn "), .warning) + } + + func testCheckupPresentationCoversStatesTimelineAndHostWarning() throws { + let summary = DoctorSummary(payload: try testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ssh ok", domain: "Device") + ]).decode(DoctorPayload.self)) + let headlines: [DoctorWorkflowState: String] = [ + .idle: "Run a checkup to inspect this Time Capsule.", + .running: "Checkup is running.", + .passed: "Checkup passed.", + .warning: "Checkup found warnings.", + .failed: "Checkup failed.", + .runFailed: "Checkup could not complete." + ] + + for state in DoctorWorkflowState.allCases { + let presentation = CheckupPresentation(summary: summary, state: state) + + XCTAssertEqual(presentation.headline, headlines[state], "Unexpected headline for \(state).") + XCTAssertEqual(presentation.primaryAction, state == .running ? nil : .runCheckup) + } + + let stageEvent = BackendEvent( + type: "stage", + operation: "doctor", + stage: "run_checks", + risk: "local_read", + cancellable: true, + description: "checking" + ) + let running = CheckupPresentation( + summary: summary, + state: .running, + events: [stageEvent], + currentStage: OperationStageState(event: stageEvent), + hostWarning: HostCompatibilityWarning(title: "macOS Warning", message: "Known Time Machine issue.") + ) + + XCTAssertEqual(running.timeline.count, 1) + XCTAssertEqual(running.timeline.first?.title, "Running Checkup") + XCTAssertEqual(running.hostWarning?.message, "Known Time Machine issue.") } func testOverviewPresentationPromptsForMissingPassword() throws { @@ -204,6 +271,137 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(presentation.items.first?.title, "Uploading") } + func testMaintenanceActionPolicyCoversAllStates() { + let expectedActivate: [MaintenanceOperationState: MaintenanceUserAction] = [ + .idle: .planActivation, + .planReady: .runActivation, + .succeeded: .planActivation, + .failed: .planActivation + ] + let expectedUninstall: [MaintenanceOperationState: MaintenanceUserAction] = [ + .idle: .planUninstall, + .planReady: .runUninstall, + .planStale: .planUninstall, + .succeeded: .planUninstall, + .failed: .planUninstall + ] + let expectedFsck: [MaintenanceOperationState: MaintenanceUserAction] = [ + .idle: .findVolumes, + .listReady: .planFsck, + .planReady: .runFsck, + .planStale: .planFsck, + .succeeded: .findVolumes, + .failed: .findVolumes + ] + let expectedRepair: [MaintenanceOperationState: MaintenanceUserAction] = [ + .idle: .scanMetadata, + .scanReady: .repairMetadata, + .scanStale: .scanMetadata, + .repaired: .scanMetadata, + .failed: .scanMetadata + ] + + for state in MaintenanceOperationState.allCases { + XCTAssertEqual(primaryAction(.activate, state: state), expectedActivate[state], "Unexpected activate action for \(state).") + XCTAssertEqual(primaryAction(.uninstall, state: state), expectedUninstall[state], "Unexpected uninstall action for \(state).") + XCTAssertEqual(primaryAction(.fsck, state: state), expectedFsck[state], "Unexpected fsck action for \(state).") + XCTAssertEqual(primaryAction(.repairXattrs, state: state), expectedRepair[state], "Unexpected repair action for \(state).") + } + + XCTAssertNil(primaryAction(.fsck, state: .listReady, hasSelectedFsckTarget: false)) + XCTAssertEqual(primaryAction(.repairXattrs, state: .scanReady, canRepairXattrs: false), .scanMetadata) + XCTAssertEqual(MaintenanceActionPolicy.secondaryActions(workflow: .fsck, state: .planReady), [.planFsck, .findVolumes]) + XCTAssertEqual(MaintenanceActionPolicy.secondaryActions(workflow: .repairXattrs, state: .scanReady), [.scanMetadata]) + } + + func testMaintenanceStatusMessagesCoverAllStates() { + for state in MaintenanceOperationState.allCases { + XCTAssertFalse(state.maintenanceStatusMessage(for: .activate).isEmpty) + XCTAssertFalse(state.maintenanceStatusMessage(for: .repairXattrs).isEmpty) + } + + XCTAssertEqual(MaintenanceOperationState.listReady.maintenanceStatusMessage(for: .fsck), "Choose a volume, then plan disk repair.") + XCTAssertEqual(MaintenanceOperationState.scanReady.maintenanceStatusMessage(for: .repairXattrs), "Review the scan before repairing metadata.") + XCTAssertEqual(MaintenanceOperationState.scanReady.maintenanceStatusMessage(for: .activate), "Scan Ready") + } + + func testMaintenancePresentationBuildsWorkflowPlansAndCompletions() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationResultPayload(alreadyActive: true)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [testFsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile() + + store.planActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + var presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .activate) + XCTAssertEqual(presentation.detail.primaryAction, .runActivation) + XCTAssertEqual(presentation.detail.plan?.title, "Start SMB Plan") + XCTAssertEqual(presentation.detail.plan?.rows.first, PresentationRow(label: "Device", value: profile.title)) + + store.runActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .succeeded && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.completion?.title, "Start SMB Complete") + XCTAssertTrue(presentation.detail.completion?.rows.contains(PresentationRow(label: "Already Active", value: "yes")) == true) + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .uninstall) + XCTAssertEqual(presentation.detail.primaryAction, .runUninstall) + XCTAssertEqual(presentation.detail.plan?.warnings, ["Uninstall removes managed SMB files from this Time Capsule."]) + + store.refreshFsckTargets(password: "pw") + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .fsck) + XCTAssertEqual(presentation.detail.primaryAction, .planFsck) + + store.planFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.plan?.title, "Disk Repair Plan") + XCTAssertEqual(presentation.detail.plan?.warnings, ["Disk repair can modify the selected Time Capsule volume."]) + + store.repairPath = "/Volumes/Data" + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .repairXattrs) + XCTAssertEqual(presentation.detail.primaryAction, .repairMetadata) + XCTAssertEqual(presentation.detail.plan?.title, "Metadata Scan") + XCTAssertEqual(presentation.detail.plan?.warnings, ["Metadata repair modifies files under the selected local SMB mount."]) + } + + func testMaintenanceTimelineFiltersByWorkflowOperation() { + let presentation = MaintenanceTimelinePresentation(events: [ + BackendEvent(type: "stage", operation: "doctor", stage: "run_checks"), + BackendEvent(type: "stage", operation: "uninstall", stage: "remove_payload", description: "removing") + ], currentStage: nil, workflow: .uninstall) + + XCTAssertEqual(presentation.items.count, 1) + XCTAssertEqual(presentation.items.first?.title, "Remove Payload") + } + private func netbsd4DeployPlan() -> JSONValue { .object([ "schema_version": .number(1), @@ -223,6 +421,28 @@ final class DashboardPresentationTests: XCTestCase { ]) } + private func primaryAction( + _ workflow: MaintenanceWorkflow, + state: MaintenanceOperationState, + hasSelectedFsckTarget: Bool = true, + canRepairXattrs: Bool = true + ) -> MaintenanceUserAction? { + MaintenanceActionPolicy.primaryAction(for: MaintenanceActionContext( + workflow: workflow, + state: state, + hasSelectedFsckTarget: hasSelectedFsckTarget, + canRepairXattrs: canRepairXattrs + )) + } + + private func doctorCheckWithoutDomain(status: String, message: String) -> JSONValue { + .object([ + "status": .string(status), + "message": .string(message), + "details": .object([:]) + ]) + } + private func makeProfile( id: String = "device-one", payloadFamily: String = "netbsd6_samba4" diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift index e1303d6e..380696c2 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift @@ -53,6 +53,26 @@ final class FlashWorkflowStoreTests: XCTestCase { XCTAssertFalse(eligibility.writeAllowed) } + func testFlashPresentationExposesAllActionsButEnablesOnlyReadOnlyEntryPoint() { + let readOnlyStates: Set = [ + .eligibleForReadOnlyAnalysis, + .planAvailable, + .writeLocked, + .awaitingStrongConfirmation + ] + + for state in FlashWorkflowState.allCases { + let presentation = FlashPresentation(state: state, message: "message") + + XCTAssertEqual(presentation.actions, [.backupAndInspect, .patchBootHook, .restoreFirmware]) + XCTAssertEqual(presentation.message, "message") + XCTAssertEqual(presentation.stateTitle, state.title) + XCTAssertEqual(presentation.isEnabled(.backupAndInspect), readOnlyStates.contains(state), "Unexpected backup action state for \(state).") + XCTAssertFalse(presentation.isEnabled(.patchBootHook), "Patch action must remain disabled for \(state).") + XCTAssertFalse(presentation.isEnabled(.restoreFirmware), "Restore action must remain disabled for \(state).") + } + } + private func makeProfile(payloadFamily: String) throws -> DeviceProfile { DeviceProfile.make( id: "device-one", diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift index 88a429d1..6b174fd6 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift @@ -28,10 +28,10 @@ final class MaintenanceStoreTests: XCTestCase { 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()) + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) ]), .init(events: [ - BackendEvent(type: "result", operation: "activate", ok: true, payload: activationResultPayload(alreadyActive: true)) + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationResultPayload(alreadyActive: true)) ]) ]) let store = MaintenanceStore(backend: BackendClient(runner: runner)) @@ -74,14 +74,14 @@ final class MaintenanceStoreTests: XCTestCase { func testActivationRequiresPlanAndHandlesConfirmationReplay() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ - BackendEvent(type: "result", operation: "activate", ok: true, payload: activationPlanPayload()) + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) ]), .init(events: [ confirmationRequired(operation: "activate", id: "activate-confirm") ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), .init(events: [ BackendEvent(type: "stage", operation: "activate", stage: "run_activation", risk: "remote_write", cancellable: false), - BackendEvent(type: "result", operation: "activate", ok: true, payload: activationResultPayload(alreadyActive: false)) + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationResultPayload(alreadyActive: false)) ]) ]) let backend = BackendClient(runner: runner) @@ -132,16 +132,16 @@ final class MaintenanceStoreTests: XCTestCase { func testUninstallPlanStaleRunAndBackendError() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ - BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) ]), .init(events: [ - BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) ]), .init(events: [ - BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallResultPayload(waited: false, verified: false)) + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallResultPayload(waited: false, verified: false)) ]), .init(events: [ - BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) ]), .init(events: [ BackendEvent( @@ -209,14 +209,14 @@ final class MaintenanceStoreTests: XCTestCase { func testUninstallConfirmationReplayCompletes() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ - BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) ]), .init(events: [ confirmationRequired(operation: "uninstall", id: "uninstall-confirm") ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), .init(events: [ BackendEvent(type: "stage", operation: "uninstall", stage: "remove_payload", risk: "remote_write", cancellable: false), - BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallResultPayload(waited: true, verified: true)) + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallResultPayload(waited: true, verified: true)) ]) ]) let backend = BackendClient(runner: runner) @@ -238,19 +238,19 @@ final class MaintenanceStoreTests: XCTestCase { func testFsckListPlanStaleAndRunConfirmation() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [fsckTargetPayload(name: "Data")])) + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [testFsckTargetPayload(name: "Data")])) ]), .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload()) + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) ]), .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload()) + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) ]), .init(events: [ confirmationRequired(operation: "fsck", id: "fsck-confirm") ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckResultPayload(returncode: 0)) + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckResultPayload(returncode: 0)) ]) ]) let backend = BackendClient(runner: runner) @@ -286,16 +286,16 @@ final class MaintenanceStoreTests: XCTestCase { func testFsckEmptyListPlanValidationAndFalseResult() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [])) + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [])) ]), .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [fsckTargetPayload(name: "Data")])) + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [testFsckTargetPayload(name: "Data")])) ]), .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload()) + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) ]), .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: false, payload: fsckResultPayload(returncode: 1)) + BackendEvent(type: "result", operation: "fsck", ok: false, payload: testFsckResultPayload(returncode: 1)) ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) ]) let store = MaintenanceStore(backend: BackendClient(runner: runner)) @@ -318,16 +318,16 @@ final class MaintenanceStoreTests: XCTestCase { } func testFsckFallbackVolumeParamTargetChangeBackendErrorAndMalformedPayloads() async throws { - let targetWithoutName = fsckTargetPayload(name: nil, device: "/dev/dk3", mountpoint: "/Volumes/External") + let targetWithoutName = testFsckTargetPayload(name: nil, device: "/dev/dk3", mountpoint: "/Volumes/External") let runner = StoreTestRunner(responses: [ .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [ targetWithoutName, - fsckTargetPayload(name: "Data") + testFsckTargetPayload(name: "Data") ])) ]), .init(events: [ - BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload(target: targetWithoutName, device: "/dev/dk3", mountpoint: "/Volumes/External")) + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload(target: targetWithoutName, device: "/dev/dk3", mountpoint: "/Volumes/External")) ]), .init(events: [ BackendEvent( @@ -371,16 +371,16 @@ final class MaintenanceStoreTests: XCTestCase { 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)) + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) ]), .init(events: [ - BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 2, repairable: 1)) + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) ]), .init(events: [ confirmationRequired(operation: "repair-xattrs", id: "repair-confirm") ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), .init(events: [ - BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 2, repairable: 0)) + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 0)) ]), .init(events: [ BackendEvent( @@ -429,7 +429,7 @@ final class MaintenanceStoreTests: XCTestCase { func testRepairXattrsMissingPathZeroRepairableAndMalformedPayload() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ - BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 0, repairable: 0)) + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 0, repairable: 0)) ]), .init(events: [ BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: .object(["schema_version": .string("wrong")])) @@ -453,7 +453,7 @@ final class MaintenanceStoreTests: XCTestCase { func testClearResetsMaintenanceState() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ - BackendEvent(type: "result", operation: "activate", ok: true, payload: activationPlanPayload()) + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) ]) ]) let store = MaintenanceStore(backend: BackendClient(runner: runner)) @@ -489,124 +489,4 @@ final class MaintenanceStoreTests: XCTestCase { ) } - private func activationPlanPayload() -> JSONValue { - .object([ - "schema_version": .number(1), - "actions": .array([.object(["type": .string("run_script")])]), - "post_activation_checks": .array([ - .object(["id": .string("runtime_ready"), "description": .string("runtime ready")]) - ]), - "counts": .object(["actions": .number(1)]), - "summary": .string("NetBSD4 activation dry-run plan generated.") - ]) - } - - private func activationResultPayload(alreadyActive: Bool) -> JSONValue { - .object([ - "schema_version": .number(1), - "already_active": .bool(alreadyActive), - "summary": .string(alreadyActive ? "NetBSD4 payload was already active." : "NetBSD4 activation completed.") - ]) - } - - private func uninstallPlanPayload() -> JSONValue { - .object([ - "schema_version": .number(1), - "host": .string("root@10.0.0.2"), - "volume_roots": .array([.string("/Volumes/dk2")]), - "payload_dirs": .array([.string("/Volumes/dk2/.samba4")]), - "remote_actions": .array([.object(["type": .string("remove_path")])]), - "requires_reboot": .bool(true), - "reboot_required": .bool(true), - "post_uninstall_checks": .array([ - .object(["id": .string("managed_files_absent"), "description": .string("managed files absent")]) - ]), - "counts": .object(["payload_dirs": .number(1)]), - "summary": .string("uninstall dry-run plan generated.") - ]) - } - - private func uninstallResultPayload(waited: Bool, verified: Bool) -> JSONValue { - .object([ - "schema_version": .number(1), - "summary": .string(verified ? "uninstall completed." : "uninstall completed without post-reboot verification."), - "requires_reboot": .bool(true), - "rebooted": .bool(false), - "reboot_requested": .bool(true), - "waited": .bool(waited), - "verified": .bool(verified) - ]) - } - - private func fsckListPayload(targets: [JSONValue]) -> JSONValue { - .object([ - "schema_version": .number(1), - "targets": .array(targets), - "counts": .object(["targets": .number(Double(targets.count))]), - "summary": .string("found \(targets.count) mounted HFS volume(s).") - ]) - } - - private func fsckTargetPayload( - name: String?, - device: String = "/dev/dk2", - mountpoint: String = "/Volumes/dk2" - ) -> JSONValue { - var payload: [String: JSONValue] = [ - "device": .string(device), - "mountpoint": .string(mountpoint), - "builtin": .bool(true) - ] - if let name { - payload["name"] = .string(name) - } - return .object(payload) - } - - private func fsckPlanPayload( - target: JSONValue? = nil, - device: String = "/dev/dk2", - mountpoint: String = "/Volumes/dk2" - ) -> JSONValue { - .object([ - "schema_version": .number(1), - "target": target ?? fsckTargetPayload(name: "Data"), - "device": .string(device), - "mountpoint": .string(mountpoint), - "reboot_required": .bool(true), - "wait_after_reboot": .bool(false), - "summary": .string("fsck dry-run plan generated.") - ]) - } - - private func fsckResultPayload(returncode: Int) -> JSONValue { - .object([ - "schema_version": .number(1), - "device": .string("/dev/dk2"), - "mountpoint": .string("/Volumes/dk2"), - "returncode": .number(Double(returncode)), - "reboot_requested": .bool(false), - "waited": .bool(false), - "verified": .bool(false), - "summary": .string("fsck completed.") - ]) - } - - private func repairPayload(findings: Int, repairable: Int) -> JSONValue { - .object([ - "schema_version": .number(1), - "returncode": .number(0), - "root": .string("/Volumes/Data"), - "finding_count": .number(Double(findings)), - "repairable_count": .number(Double(repairable)), - "counts": .object([ - "findings": .number(Double(findings)), - "repairable": .number(Double(repairable)) - ]), - "stats": .object([:]), - "report": .string("report"), - "summary": .string("repair-xattrs found \(findings) issue(s), \(repairable) repairable."), - "summary_text": .string("repair-xattrs found \(findings) issue(s), \(repairable) repairable.") - ]) - } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift index 4c691bcf..75af6a8a 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -332,3 +332,124 @@ func testDeployResultPayload( "summary": .string("deployment completed.") ]) } + +func testActivationPlanPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "actions": .array([.object(["type": .string("run_script")])]), + "post_activation_checks": .array([ + .object(["id": .string("runtime_ready"), "description": .string("runtime ready")]) + ]), + "counts": .object(["actions": .number(1)]), + "summary": .string("NetBSD4 activation dry-run plan generated.") + ]) +} + +func testActivationResultPayload(alreadyActive: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "already_active": .bool(alreadyActive), + "summary": .string(alreadyActive ? "NetBSD4 payload was already active." : "NetBSD4 activation completed.") + ]) +} + +func testUninstallPlanPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "host": .string("root@10.0.0.2"), + "volume_roots": .array([.string("/Volumes/dk2")]), + "payload_dirs": .array([.string("/Volumes/dk2/.samba4")]), + "remote_actions": .array([.object(["type": .string("remove_path")])]), + "requires_reboot": .bool(true), + "reboot_required": .bool(true), + "post_uninstall_checks": .array([ + .object(["id": .string("managed_files_absent"), "description": .string("managed files absent")]) + ]), + "counts": .object(["payload_dirs": .number(1)]), + "summary": .string("uninstall dry-run plan generated.") + ]) +} + +func testUninstallResultPayload(waited: Bool, verified: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "summary": .string(verified ? "uninstall completed." : "uninstall completed without post-reboot verification."), + "requires_reboot": .bool(true), + "rebooted": .bool(false), + "reboot_requested": .bool(true), + "waited": .bool(waited), + "verified": .bool(verified) + ]) +} + +func testFsckListPayload(targets: [JSONValue]) -> JSONValue { + .object([ + "schema_version": .number(1), + "targets": .array(targets), + "counts": .object(["targets": .number(Double(targets.count))]), + "summary": .string("found \(targets.count) mounted HFS volume(s).") + ]) +} + +func testFsckTargetPayload( + name: String?, + device: String = "/dev/dk2", + mountpoint: String = "/Volumes/dk2" +) -> JSONValue { + var payload: [String: JSONValue] = [ + "device": .string(device), + "mountpoint": .string(mountpoint), + "builtin": .bool(true) + ] + if let name { + payload["name"] = .string(name) + } + return .object(payload) +} + +func testFsckPlanPayload( + target: JSONValue? = nil, + device: String = "/dev/dk2", + mountpoint: String = "/Volumes/dk2" +) -> JSONValue { + .object([ + "schema_version": .number(1), + "target": target ?? testFsckTargetPayload(name: "Data"), + "device": .string(device), + "mountpoint": .string(mountpoint), + "reboot_required": .bool(true), + "wait_after_reboot": .bool(false), + "summary": .string("fsck dry-run plan generated.") + ]) +} + +func testFsckResultPayload(returncode: Int) -> JSONValue { + .object([ + "schema_version": .number(1), + "device": .string("/dev/dk2"), + "mountpoint": .string("/Volumes/dk2"), + "returncode": .number(Double(returncode)), + "reboot_requested": .bool(false), + "waited": .bool(false), + "verified": .bool(false), + "summary": .string("fsck completed.") + ]) +} + +func testRepairXattrsPayload(findings: Int, repairable: Int) -> JSONValue { + .object([ + "schema_version": .number(1), + "returncode": .number(0), + "root": .string("/Volumes/Data"), + "finding_count": .number(Double(findings)), + "repairable_count": .number(Double(repairable)), + "counts": .object([ + "findings": .number(Double(findings)), + "repairable": .number(Double(repairable)) + ]), + "stats": .object([:]), + "report": .string("report"), + "summary": .string("repair-xattrs found \(findings) issue(s), \(repairable) repairable."), + "summary_text": .string("repair-xattrs found \(findings) issue(s), \(repairable) repairable.") + ]) +} From c9da6c45f05c0023c3a628c1978246b5e8adb12b Mon Sep 17 00:00:00 2001 From: James Chang Date: Thu, 21 May 2026 17:36:56 -0700 Subject: [PATCH 025/129] Add bundled runtime validation and first-launch discovery monitor --- .../TimeCapsuleSMBApp/App/AppStore.swift | 26 +- .../TimeCapsuleSMBApp/App/BundleLayout.swift | 32 ++- .../Backend/OperationParams.swift | 26 +- .../Policies/SMBAddressPolicy.swift | 100 +++++++ .../Profiles/SMBAccountResolver.swift | 60 ++++ .../Resources/en.lproj/Localizable.strings | 66 ++++- .../Views/AddDevice/AddDeviceView.swift | 10 + .../Components/BlockingProgressOverlay.swift | 38 +++ .../Views/Dashboard/CheckupTab.swift | 65 +++-- .../Views/Dashboard/InstallTab.swift | 69 +++-- .../Views/Shell/ActivityView.swift | 136 ++++++++- .../Views/Shell/ContentView.swift | 26 +- .../Views/Shell/DeviceListOverviewView.swift | 150 +++++++++- .../Views/Shell/SidebarView.swift | 33 ++- .../ActivityProgressTextAnimator.swift | 44 +++ .../Workflows/ActivityStore.swift | 2 +- .../Workflows/AddDeviceFlowStore.swift | 25 ++ .../Workflows/AddDevicePresentation.swift | 34 +++ .../BlockingProgressPresenting.swift | 7 + .../Workflows/CheckupPresentation.swift | 15 + .../DashboardOverviewPresentation.swift | 97 +++---- .../Workflows/DeployWorkflowStore.swift | 40 ++- .../Workflows/DeviceDashboardSession.swift | 10 +- .../DeviceDiscoveryMonitorStore.swift | 264 ++++++++++++++++++ .../Workflows/InstallPresentation.swift | 24 ++ .../ActivityProgressTextAnimatorTests.swift | 56 ++++ .../ActivityStoreTests.swift | 28 ++ .../AddDeviceFlowStoreTests.swift | 44 +++ .../AddDevicePresentationTests.swift | 38 +++ .../BundleLayoutTests.swift | 53 +++- .../DashboardPresentationTests.swift | 57 +++- .../DashboardStoreTests.swift | 39 ++- .../DeployWorkflowStoreTests.swift | 59 +++- .../DeviceDiscoveryMonitorStoreTests.swift | 221 +++++++++++++++ .../HelperLocatorTests.swift | 9 +- .../PendingConfirmationTests.swift | 21 ++ .../SMBAccountResolverTests.swift | 118 ++++++++ .../SMBAddressPolicyTests.swift | 92 ++++++ macos/TimeCapsuleSMB/tools/package_app.py | 64 ++++- src/timecapsulesmb/app/ops/deploy.py | 20 +- src/timecapsulesmb/services/deploy.py | 16 +- tests/test_app_api.py | 28 +- tests/test_macos_package_app.py | 97 +++++++ tests/test_storage_runtime.py | 40 +++ 44 files changed, 2300 insertions(+), 199 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/SMBAccountResolver.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BlockingProgressPresenting.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDevicePresentationTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryMonitorStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAccountResolverTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift create mode 100644 tests/test_macos_package_app.py diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift index 22f389c8..3726ef71 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift @@ -5,12 +5,14 @@ import Foundation final class AppStore: ObservableObject { @Published var selectedDeviceID: DeviceProfile.ID? @Published var showingAddDevice = false + @Published var showingActivity = false let appReadinessStore: AppReadinessStore let deviceRegistry: DeviceRegistryStore let operationCoordinator: OperationCoordinator let passwordStore: PasswordStore let activityStore: ActivityStore + let discoveryMonitor: DeviceDiscoveryMonitorStore private var cancellables: Set = [] @@ -30,13 +32,19 @@ final class AppStore: ObservableObject { deviceRegistry: DeviceRegistryStore, operationCoordinator: OperationCoordinator, passwordStore: PasswordStore, - activityStore: ActivityStore? = nil + activityStore: ActivityStore? = nil, + discoveryMonitor: DeviceDiscoveryMonitorStore? = nil ) { self.appReadinessStore = appReadinessStore self.deviceRegistry = deviceRegistry self.operationCoordinator = operationCoordinator self.passwordStore = passwordStore self.activityStore = activityStore ?? ActivityStore(coordinator: operationCoordinator) + self.discoveryMonitor = discoveryMonitor ?? DeviceDiscoveryMonitorStore( + coordinator: operationCoordinator, + readinessStore: appReadinessStore, + registry: deviceRegistry + ) appReadinessStore.objectWillChange .sink { [weak self] _ in @@ -58,6 +66,11 @@ final class AppStore: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) + self.discoveryMonitor.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) deviceRegistry.$profiles .sink { [weak self] profiles in Task { @MainActor in @@ -79,16 +92,25 @@ final class AppStore: ObservableObject { await deviceRegistry.load() await refreshPasswordStates() appReadinessStore.start() + discoveryMonitor.startMonitoring() } func select(_ profile: DeviceProfile) { selectedDeviceID = profile.id showingAddDevice = false + showingActivity = false } func showAddDevice() { selectedDeviceID = nil showingAddDevice = true + showingActivity = false + } + + func showActivity() { + selectedDeviceID = nil + showingAddDevice = false + showingActivity = true } func dashboardSummary(for profile: DeviceProfile) -> DeviceDashboardSummary { @@ -146,6 +168,7 @@ final class AppStore: ObservableObject { if selectedDeviceID == profile.id { selectedDeviceID = deviceRegistry.profiles.first?.id showingAddDevice = false + showingActivity = false } } @@ -169,6 +192,7 @@ final class AppStore: ObservableObject { selectedDeviceID = profiles.first?.id if !profiles.isEmpty { showingAddDevice = false + showingActivity = false } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift index bfb51eec..9c8a8212 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift @@ -14,7 +14,10 @@ public enum BundleRuntimeIssueSeverity: String, CaseIterable, Equatable, Sendabl public enum BundleRuntimeIssueCode: String, CaseIterable, Equatable, Sendable { case helperMissing case helperNotExecutable + case pythonRuntimeMissing + case pythonExecutableMissing case distributionRootMissing + case distributionArtifactsMissing case toolsDirectoryMissing case installValidationFailed case helperLaunchFailed @@ -75,7 +78,7 @@ public struct BundleLayout: Equatable, Sendable { self.helperURL = helperURL self.distributionRootURL = distributionRootURL ?? resourceURL.appendingPathComponent("Distribution", isDirectory: true) self.toolsBinURL = toolsBinURL ?? resourceURL.appendingPathComponent("Tools/bin", isDirectory: true) - self.pythonRuntimeURL = pythonRuntimeURL + self.pythonRuntimeURL = pythonRuntimeURL ?? resourceURL.appendingPathComponent("Python", isDirectory: true) self.applicationSupportURL = applicationSupportURL self.configURL = configURL ?? applicationSupportURL.appendingPathComponent(".env") self.stateDirectoryURL = stateDirectoryURL ?? applicationSupportURL @@ -126,6 +129,26 @@ public struct BundleLayout: Equatable, Sendable { recovery: "Reinstall TimeCapsuleSMB." )) } + if let pythonRuntimeURL { + if !isDirectory(pythonRuntimeURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .pythonRuntimeMissing, + severity: .error, + message: "The bundled Python runtime is missing.", + recovery: "Reinstall TimeCapsuleSMB." + )) + } else { + let python = pythonRuntimeURL.appendingPathComponent("bin/python") + if !fileManager.isExecutableFile(atPath: python.path) { + issues.append(BundleRuntimeIssue( + code: .pythonExecutableMissing, + severity: .error, + message: "The bundled Python executable is missing or not executable.", + recovery: "Reinstall TimeCapsuleSMB." + )) + } + } + } if !isDirectory(distributionRootURL, fileManager: fileManager) { issues.append(BundleRuntimeIssue( code: .distributionRootMissing, @@ -133,6 +156,13 @@ public struct BundleLayout: Equatable, Sendable { message: "The bundled TimeCapsuleSMB distribution is missing.", recovery: "Reinstall TimeCapsuleSMB." )) + } else if !isDirectory(distributionRootURL.appendingPathComponent("bin", isDirectory: true), fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .distributionArtifactsMissing, + severity: .error, + message: "The bundled TimeCapsuleSMB payload artifacts are missing.", + recovery: "Reinstall TimeCapsuleSMB." + )) } if !isDirectory(toolsBinURL, fileManager: fileManager) { issues.append(BundleRuntimeIssue( diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift index a8b39080..01801b27 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift @@ -63,36 +63,54 @@ enum OperationParams { noReboot: Bool, noWait: Bool, nbnsEnabled: Bool, + internalShareUseDiskRoot: Bool = false, + anyProtocol: Bool = false, debugLogging: Bool, mountWait: Double, password: String ) -> [String: JSONValue] { - withCredentials([ + var params: [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) - ], password: password) + ] + if internalShareUseDiskRoot { + params["internal_share_use_disk_root"] = .bool(true) + } + if anyProtocol { + params["any_protocol"] = .bool(true) + } + return withCredentials(params, password: password) } static func deployRun( noReboot: Bool, noWait: Bool, nbnsEnabled: Bool, + internalShareUseDiskRoot: Bool = false, + anyProtocol: Bool = false, debugLogging: Bool, mountWait: Double, password: String ) -> [String: JSONValue] { - withCredentials([ + var params: [String: JSONValue] = [ "dry_run": .bool(false), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "nbns_enabled": .bool(nbnsEnabled), "debug_logging": .bool(debugLogging), "mount_wait": .number(mountWait) - ], password: password) + ] + if internalShareUseDiskRoot { + params["internal_share_use_disk_root"] = .bool(true) + } + if anyProtocol { + params["any_protocol"] = .bool(true) + } + return withCredentials(params, password: password) } static func uninstallPlan(noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift new file mode 100644 index 00000000..44e0c67e --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift @@ -0,0 +1,100 @@ +import Foundation + +enum SMBAddressPolicy { + static func url(for profile: DeviceProfile, account: String? = nil) -> URL? { + guard let host = preferredHost(for: profile) else { + return nil + } + return url(host: host, account: account) + } + + static func preferredHost(for profile: DeviceProfile) -> String? { + if let serviceHost = bonjourSMBServiceHost(for: profile) { + return serviceHost + } + if let hostname = normalizedAddressHost(profile.hostname) { + return hostname + } + return normalizedAddressHost(profile.host) + } + + static func credentialServerCandidates(for profile: DeviceProfile) -> [String] { + unique([ + normalizedAddressHost(profile.hostname), + normalizedAddressHost(profile.host) + ]) + } + + private static func bonjourSMBServiceHost(for profile: DeviceProfile) -> String? { + if let fullname = profile.bonjourFullname?.trimmingCharacters(in: .whitespacesAndNewlines), + !fullname.isEmpty { + let trimmed = fullname.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let lowercased = trimmed.lowercased() + if lowercased.hasSuffix("._smb._tcp.local") { + return trimmed + } + for service in ["._airport._tcp.local", "._adisk._tcp.local", "._device-info._tcp.local"] { + if lowercased.hasSuffix(service) { + return String(trimmed.dropLast(service.count)) + "._smb._tcp.local" + } + } + } + + guard let bonjourName = profile.bonjourName?.trimmingCharacters(in: .whitespacesAndNewlines), + !bonjourName.isEmpty else { + return nil + } + return "\(bonjourName)._smb._tcp.local" + } + + private static func url(host: String, account: String?) -> URL? { + guard let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { + return nil + } + let accountPrefix: String + if let account = account?.trimmingCharacters(in: .whitespacesAndNewlines), + !account.isEmpty, + let encodedAccount = account.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) { + accountPrefix = "\(encodedAccount)@" + } else { + accountPrefix = "" + } + return URL(string: "smb://\(accountPrefix)\(encodedHost)") + } + + private static func normalizedAddressHost(_ value: String?) -> String? { + guard var candidate = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !candidate.isEmpty else { + return nil + } + + if let parsedURL = URL(string: candidate), let parsedHost = parsedURL.host, !parsedHost.isEmpty { + candidate = parsedHost + } else { + candidate = candidate.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? candidate + candidate = candidate.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) + .last + .map(String.init) ?? candidate + } + + let normalized = candidate + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalized.isEmpty ? nil : normalized + } + + private static func unique(_ values: [String?]) -> [String] { + var seen: Set = [] + var ordered: [String] = [] + for value in values { + guard let value else { continue } + let key = value.lowercased() + if seen.insert(key).inserted { + ordered.append(value) + } + } + return ordered + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/SMBAccountResolver.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/SMBAccountResolver.swift new file mode 100644 index 00000000..ea1e241a --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/SMBAccountResolver.swift @@ -0,0 +1,60 @@ +import Foundation +import Security + +protocol SMBAccountResolving { + func account(for profile: DeviceProfile) -> String? +} + +struct KeychainSMBAccountResolver: SMBAccountResolving { + private let keychainClient: KeychainClient + + init(keychainClient: KeychainClient = SystemKeychainClient()) { + self.keychainClient = keychainClient + } + + func account(for profile: DeviceProfile) -> String? { + for server in serverCandidates(for: profile) { + if let account = account(forServer: server) { + return account + } + } + return nil + } + + private func serverCandidates(for profile: DeviceProfile) -> [String] { + var candidates = SMBAddressPolicy.credentialServerCandidates(for: profile) + for server in Array(candidates) { + let lowercased = server.lowercased() + if lowercased != server && !candidates.contains(lowercased) { + candidates.append(lowercased) + } + } + return candidates + } + + private func account(forServer server: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassInternetPassword, + kSecAttrProtocol as String: kSecAttrProtocolSMB, + kSecAttrServer as String: server, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = keychainClient.copyMatching(query, result: &result) + guard status == errSecSuccess, + let attributes = result as? [String: Any], + let account = attributes[kSecAttrAccount as String] as? String else { + return nil + } + let trimmed = account.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +struct EmptySMBAccountResolver: SMBAccountResolving { + func account(for profile: DeviceProfile) -> String? { + nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index ea90e3cb..78311ad6 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -18,6 +18,12 @@ "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.progress.configuring.message" = "Verifying access and preparing this Time Capsule. This can take a few seconds."; +"add_device.progress.configuring.title" = "Connecting to Time Capsule"; +"add_device.progress.discovering.message" = "Browsing for nearby AirPort Bonjour services..."; +"add_device.progress.discovering.title" = "Discovering Time Capsules"; +"add_device.progress.saving.message" = "Writing the saved device profile and Keychain password."; +"add_device.progress.saving.title" = "Saving Device"; "add_device.reset" = "Reset"; "add_device.save_device" = "Save Device"; "add_device.saved" = "Saved %@"; @@ -73,6 +79,8 @@ "checkup.presentation.headline.run_failed" = "Checkup could not complete."; "checkup.presentation.headline.running" = "Checkup is running."; "checkup.presentation.headline.warning" = "Checkup found warnings."; +"checkup.progress.running.message" = "Running local and remote diagnostic checks.\nThis can take a few minutes..."; +"checkup.progress.running.title" = "Running Checkup"; "checkup.presentation.row.fail" = "Fail"; "checkup.presentation.row.info" = "Info"; "checkup.presentation.row.pass" = "Pass"; @@ -109,7 +117,7 @@ "dashboard.action.replace_password" = "Replace Password"; "dashboard.action.run_checkup" = "Run Checkup"; "dashboard.action.save_password" = "Save Password"; -"dashboard.action.start_smb" = "Start SMB"; +"dashboard.action.start_smb" = "Activate"; "dashboard.action.view_checkup" = "View Checkup"; "dashboard.header.last_checked" = "Last checked"; "dashboard.health.check_counts" = "PASS %d, WARN %d, FAIL %d"; @@ -119,9 +127,10 @@ "dashboard.health.connection.password_invalid" = "The saved password was rejected by the Time Capsule."; "dashboard.health.connection.password_missing" = "Save the Time Capsule password before running checkups or installs."; "dashboard.health.connection.running" = "A backend operation is using this profile."; +"dashboard.health.checkup" = "Checkup"; "dashboard.health.finder_bonjour" = "Finder / Bonjour"; "dashboard.health.runtime" = "Runtime"; -"dashboard.health.runtime.activation_needed" = "This NetBSD4 device may need Start SMB after reboot."; +"dashboard.health.runtime.activation_needed" = "This NetBSD4 device may need Activate after reboot."; "dashboard.health.runtime.installing" = "Install / Update is running."; "dashboard.health.runtime.not_installed" = "SMB has not been installed from this app."; "dashboard.health.smb_auth" = "SMB Auth"; @@ -163,7 +172,7 @@ "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.presentation.warning.netbsd4_activation" = "This NetBSD4 device may need Activate to start Samba after every future reboot."; "deploy.result.default_message" = "Install completed."; "deploy.result.message" = "Message"; "deploy.result.reboot_requested" = "Reboot Requested"; @@ -174,7 +183,7 @@ "install.advanced_options" = "Advanced Options"; "install.completion.title.finished" = "Install / Update Finished"; "install.completion.title.verified" = "Install / Update Verified"; -"install.completion.warning.netbsd4" = "NetBSD4 devices may need Start SMB after a later reboot unless the boot hook is patched."; +"install.completion.warning.netbsd4" = "NetBSD4 devices may need Activate after a later reboot unless the boot hook is patched."; "install.plan.downtime.netbsd4" = "Usually under a minute; the runtime may start without reboot."; "install.plan.downtime.none" = "No reboot expected."; "install.plan.downtime.reboot" = "Several minutes while the Time Capsule reboots."; @@ -187,6 +196,8 @@ "install.plan.section.target" = "Target"; "install.plan.title.netbsd4" = "Install / Update SMB and Start Runtime"; "install.plan.title.standard" = "Install / Update SMB"; +"install.progress.deploying.message" = "Uploading and applying the managed SMB runtime. This can take a few seconds."; +"install.progress.deploying.title" = "Installing / Updating SMB"; "install.state.awaiting_confirmation" = "Review the confirmation dialog before continuing."; "install.state.deploy_failed" = "Install / Update failed."; "install.state.deployed" = "Install / Update completed."; @@ -245,10 +256,21 @@ "activity.app_ready" = "App Ready"; "activity.last_operation" = "Last operation"; "activity.no_active_operation" = "No active operation"; +"activity.timeline" = "Timeline"; +"activity.timeline.empty" = "No operation history yet."; +"discovery_monitor.last_seen.now" = "Seen now"; +"discovery_monitor.state.discovering" = "Discovering"; +"discovery_monitor.state.empty" = "No devices found"; +"discovery_monitor.state.failed" = "Discovery failed"; +"discovery_monitor.state.idle" = "Idle"; +"discovery_monitor.state.paused" = "Paused"; +"discovery_monitor.state.readiness_blocked" = "App blocked"; +"discovery_monitor.state.ready" = "Devices found"; +"discovery_monitor.state.waiting_for_readiness" = "Waiting for app readiness"; "checkup.advanced_options" = "Advanced Options"; -"checkup.option.skip_bonjour" = "Skip Bonjour"; -"checkup.option.skip_smb" = "Skip SMB"; -"checkup.option.skip_ssh" = "Skip SSH"; +"checkup.option.skip_bonjour" = "Skip Bonjour checks"; +"checkup.option.skip_smb" = "Skip SMB checks"; +"checkup.option.skip_ssh" = "Skip SSH checks"; "checkup.status.failed" = "Failed"; "checkup.status.info" = "Info"; "checkup.status.passed" = "Passed"; @@ -259,15 +281,15 @@ "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_start_smb" = "Plan Activate"; "maintenance.action.plan_uninstall" = "Plan Uninstall"; "maintenance.action.repair_metadata" = "Repair Metadata"; "maintenance.action.run_disk_repair" = "Run Disk Repair"; "maintenance.action.scan_metadata" = "Scan Metadata"; -"maintenance.action.start_smb" = "Start SMB"; +"maintenance.action.start_smb" = "Activate"; "maintenance.action.uninstall" = "Uninstall"; "maintenance.advanced_options" = "Advanced Options"; -"maintenance.presentation.activate.primary_action" = "Start SMB"; +"maintenance.presentation.activate.primary_action" = "Activate"; "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"; @@ -282,12 +304,12 @@ "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.completion.activate" = "Start SMB Complete"; +"maintenance.completion.activate" = "Activation Complete"; "maintenance.completion.fsck" = "Disk Repair Complete"; "maintenance.completion.repair_xattrs" = "Metadata Repair Complete"; "maintenance.completion.uninstall" = "Uninstall Complete"; "maintenance.fsck.no_volumes" = "Find mounted volumes before planning disk repair."; -"maintenance.plan.activate" = "Start SMB Plan"; +"maintenance.plan.activate" = "Activation Plan"; "maintenance.plan.fsck" = "Disk Repair Plan"; "maintenance.plan.repair_xattrs" = "Metadata Scan"; "maintenance.plan.row.actions" = "Actions"; @@ -329,6 +351,17 @@ "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"; +"overview.discovery.add" = "Add"; +"overview.discovery.discovering" = "Looking for Time Capsules..."; +"overview.discovery.empty" = "No nearby Time Capsules found."; +"overview.discovery.failed" = "Discovery failed."; +"overview.discovery.paused" = "Discovery will resume after the current operation finishes."; +"overview.discovery.readiness_blocked" = "Discovery is unavailable until app readiness is fixed."; +"overview.discovery.refresh" = "Refresh"; +"overview.discovery.saved" = "Saved"; +"overview.discovery.title" = "Nearby Time Capsules"; +"overview.discovery.unsaved" = "Not saved"; +"overview.discovery.waiting" = "Discovery starts after the app runtime is ready."; "operation.error.already_running" = "Another operation is already running."; "panel.connect" = "Discover And Connect"; "password_state.available" = "Available"; @@ -375,7 +408,7 @@ "recovery.action.replace_password" = "Replace Password"; "recovery.action.retry" = "Retry"; "recovery.action.run_checkup" = "Run Checkup"; -"recovery.action.start_smb" = "Start SMB"; +"recovery.action.start_smb" = "Activate"; "recovery.action.uninstall" = "Uninstall"; "screen.advanced" = "Advanced"; "screen.connect" = "Connect"; @@ -385,6 +418,7 @@ "screen.readiness" = "Readiness"; "sidebar.add_time_capsule" = "Add Time Capsule"; "sidebar.all_time_capsules" = "All Time Capsules"; +"sidebar.activity" = "Activity"; "sidebar.devices" = "Devices"; "status.activation_needed" = "Activation Needed"; "status.checking" = "Checking"; @@ -404,7 +438,7 @@ "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.activate" = "Activate"; "timeline.operation.configure" = "Add Time Capsule"; "timeline.operation.deploy" = "Install / Update"; "timeline.operation.discovery" = "Discovery"; @@ -423,7 +457,7 @@ "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_start_smb" = "Planning Activation"; "timeline.stage.planning_uninstall" = "Planning Uninstall"; "timeline.stage.rebooting" = "Rebooting"; "timeline.stage.removing_managed_files" = "Removing Managed Files"; @@ -441,6 +475,8 @@ "toggle.dry_run" = "Dry Run"; "toggle.enable_debug_logging" = "Enable Debug Logging"; "toggle.enable_nbns" = "Enable NBNS"; +"toggle.internal_share_use_disk_root" = "Internal Share Uses Disk Root"; +"toggle.any_protocol" = "Allow Any SMB Protocol"; "toggle.force_debug_logging" = "Force Debug Logging"; "toggle.no_reboot" = "No Reboot"; "toggle.no_wait" = "No Wait"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift index 1f62e619..9ade1e7a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift @@ -4,6 +4,15 @@ struct AddDeviceView: View { @ObservedObject var store: AddDeviceFlowStore var body: some View { + ZStack { + content + if let progress = AddDeviceProgressPresentation(state: store.state, currentStage: store.currentStage) { + BlockingProgressOverlay(progress: progress) + } + } + } + + private var content: some View { VStack(alignment: .leading, spacing: 14) { topSection if store.entryMode == .manual { @@ -16,6 +25,7 @@ struct AddDeviceView: View { } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .disabled(AddDeviceProgressPresentation(state: store.state, currentStage: store.currentStage) != nil) } private var topSection: some View { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift new file mode 100644 index 00000000..7272c2b2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct BlockingProgressOverlay: View { + let progress: Progress + + var body: some View { + ZStack { + Color.clear + .contentShape(Rectangle()) + .ignoresSafeArea() + + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text(progress.title) + .font(.headline) + Text(progress.message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + if let detail = progress.detail, !detail.isEmpty { + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.center) + } + } + .padding(22) + .frame(width: 340) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(radius: 18) + } + .transition(.opacity) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift index 5a170f32..9933139b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift @@ -14,47 +14,56 @@ struct CheckupTab: View { currentStage: store.currentStage, hostWarning: HostCompatibilityPolicy.warning() ) + let progress = CheckupProgressPresentation(state: store.state, currentStage: store.currentStage) - ScrollView { - VStack(alignment: .leading, spacing: 14) { - CheckupHeaderView(presentation: presentation) + ZStack { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + CheckupHeaderView(presentation: presentation) - if let warning = presentation.hostWarning { - WarningBanner(warning: warning) - } + if let warning = presentation.hostWarning { + WarningBanner(warning: warning) + } - if let action = presentation.primaryAction { - Button { - session.performCheckupAction(action, profile: profile, showDiagnostics: showDiagnostics) - } label: { - Label(action.title, systemImage: action.systemImage) + if let action = presentation.primaryAction { + Button { + session.performCheckupAction(action, profile: profile, showDiagnostics: showDiagnostics) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.borderedProminent) + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) } - .buttonStyle(.borderedProminent) - .disabled(store.isRunning || store.bonjourTimeoutValue == nil) - } - if !presentation.timeline.isEmpty { - CheckupTimelineView(items: presentation.timeline) - } + if !presentation.timeline.isEmpty { + CheckupTimelineView(items: presentation.timeline) + } - if !presentation.summaryRows.isEmpty { - SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) - } + if !presentation.summaryRows.isEmpty { + SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) + } - ForEach(presentation.domains) { domain in - CheckupDomainView(domain: domain) - } + ForEach(presentation.domains) { domain in + CheckupDomainView(domain: domain) + } - CheckupAdvancedOptionsView(store: store) + CheckupAdvancedOptionsView(store: store) - if let error = store.error { - ErrorRecoveryView(error: error) { action in - handleRecovery(action: action, error: error) + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } } } + .frame(maxWidth: .infinity, alignment: .leading) + } + .disabled(progress != nil) + + if let progress { + BlockingProgressOverlay(progress: progress) } - .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift index b09342fb..5ee5ee18 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift @@ -17,48 +17,57 @@ struct InstallTab: View { profile: profile, hostWarning: HostCompatibilityPolicy.warning() ) + let progress = InstallProgressPresentation(state: store.state, currentStage: store.currentStage) - ScrollView { - VStack(alignment: .leading, spacing: 14) { - InstallHeaderView(presentation: presentation) + ZStack { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + InstallHeaderView(presentation: presentation) - ForEach(presentation.notices, id: \.self) { notice in - Label(notice, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.yellow) - } + ForEach(presentation.notices, id: \.self) { notice in + Label(notice, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } - if let action = presentation.primaryAction { - InstallActionButton(action: action) { - session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + if let action = presentation.primaryAction { + InstallActionButton(action: action) { + session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + } + .disabled(isDisabled(action, store: store)) } - .disabled(isDisabled(action, store: store)) - } - if let timeline = presentation.timeline { - InstallTimelineView(presentation: timeline) - } + if let timeline = presentation.timeline { + InstallTimelineView(presentation: timeline) + } - if let plan = presentation.plan { - InstallPlanView(presentation: plan) - } + if let plan = presentation.plan { + InstallPlanView(presentation: plan) + } - if let completion = presentation.completion { - InstallCompletionView(presentation: completion) { action in - session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + if let completion = presentation.completion { + InstallCompletionView(presentation: completion) { action in + session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + } } - } - InstallAdvancedOptionsView(store: store) + InstallAdvancedOptionsView(store: store) - if let error = store.error { - ErrorRecoveryView(error: error) { action in - handleRecovery(action: action, error: error) + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } } } + .frame(maxWidth: .infinity, alignment: .leading) + } + .disabled(progress != nil) + + if let progress { + BlockingProgressOverlay(progress: progress) } - .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { @@ -223,6 +232,10 @@ private struct InstallAdvancedOptionsView: View { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.nbnsEnabled) + Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $store.internalShareUseDiskRoot) + } + GridRow { + Toggle(L10n.string("toggle.any_protocol"), isOn: $store.anyProtocol) Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.debugLogging) } GridRow { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift index d945fdae..99481ef5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift @@ -3,6 +3,13 @@ import SwiftUI struct ActivityCompactView: View { @ObservedObject var activityStore: ActivityStore @ObservedObject var registry: DeviceRegistryStore + @State private var messageAnimationPhase = 0 + + private let messageAnimationTimer = Timer.publish( + every: ActivityProgressTextAnimator.frameInterval, + on: .main, + in: .common + ).autoconnect() var body: some View { let snapshot = activityStore.snapshot @@ -21,6 +28,12 @@ struct ActivityCompactView: View { .padding(.horizontal) .padding(.vertical, 8) .background(Color.secondary.opacity(0.06)) + .onChange(of: ActivityProgressTextAnimator.animationIdentity(for: snapshot)) { _ in + messageAnimationPhase = 0 + } + .onReceive(messageAnimationTimer) { _ in + advanceMessageAnimation(for: snapshot) + } } @ViewBuilder @@ -30,7 +43,7 @@ struct ActivityCompactView: View { Text(title(snapshot)) .font(.caption.weight(.medium)) .lineLimit(1) - Text(snapshot.latestMessage ?? "") + Text(latestMessage(snapshot)) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) @@ -45,6 +58,24 @@ struct ActivityCompactView: View { } } + private func latestMessage(_ snapshot: ActivitySnapshot) -> String { + ActivityProgressTextAnimator.message( + snapshot.latestMessage, + isRunning: snapshot.isRunning, + phase: messageAnimationPhase + ) ?? "" + } + + private func advanceMessageAnimation(for snapshot: ActivitySnapshot) { + guard ActivityProgressTextAnimator.animationIdentity(for: snapshot) != nil else { + if messageAnimationPhase != 0 { + messageAnimationPhase = 0 + } + return + } + messageAnimationPhase = ActivityProgressTextAnimator.nextPhase(after: messageAnimationPhase) + } + private func title(_ snapshot: ActivitySnapshot) -> String { if case .device(let activeDeviceID) = snapshot.scope, let profile = registry.profile(id: activeDeviceID) { @@ -60,3 +91,106 @@ struct ActivityCompactView: View { return !latestMessage.isEmpty } } + +struct ActivityDetailView: View { + @ObservedObject var activityStore: ActivityStore + @ObservedObject var registry: DeviceRegistryStore + + var body: some View { + let snapshot = activityStore.snapshot + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: snapshot.isRunning ? "hourglass" : "clock") + .font(.title2) + .foregroundStyle(snapshot.isRunning ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 4) { + Text(title(snapshot)) + .font(.title2.weight(.semibold)) + if let latestMessage = snapshot.latestMessage, !latestMessage.isEmpty { + Text(latestMessage) + .foregroundStyle(.secondary) + } + } + Spacer() + } + + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("activity.timeline")) + .font(.headline) + if snapshot.timeline.isEmpty { + Text(L10n.string("activity.timeline.empty")) + .foregroundStyle(.secondary) + } else { + ForEach(snapshot.timeline) { item in + ActivityTimelineRow(item: item) + } + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func title(_ snapshot: ActivitySnapshot) -> String { + if case .device(let activeDeviceID) = snapshot.scope, + let profile = registry.profile(id: activeDeviceID) { + return "\(snapshot.operationTitle) - \(profile.title)" + } + return snapshot.operationTitle + } +} + +private struct ActivityTimelineRow: View { + let item: OperationTimelineItem + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: itemIcon) + .foregroundStyle(itemColor) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.body.weight(.medium)) + if let detail = item.detail, !detail.isEmpty { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .padding(.vertical, 4) + } + + private var itemIcon: String { + switch item.state { + case .pending: + return "circle" + case .running: + return "arrow.right.circle" + case .succeeded: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + } + } + + private var itemColor: Color { + switch item.state { + case .pending: + return .secondary + case .running: + return .accentColor + case .succeeded: + return .green + case .warning: + return .yellow + case .failed: + return .red + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift index 024d56e3..133d97db 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift @@ -173,6 +173,9 @@ public struct ContentView: View { private var sidebarSelection: Binding { Binding( get: { + if appStore.showingActivity { + return "activity" + } if appStore.showingAddDevice { return "add" } @@ -185,9 +188,12 @@ public struct ContentView: View { guard let value else { return } if value == "add" { appStore.showAddDevice() + } else if value == "activity" { + appStore.showActivity() } else if value == "all" { appStore.selectedDeviceID = nil appStore.showingAddDevice = false + appStore.showingActivity = false } else if value.hasPrefix("device:") { let id = String(value.dropFirst("device:".count)) if let profile = appStore.deviceRegistry.profile(id: id) { @@ -202,12 +208,15 @@ public struct ContentView: View { List(selection: sidebarSelection) { Label(L10n.string("sidebar.all_time_capsules"), systemImage: "externaldrive.connected.to.line.below") .tag("all") + Label(L10n.string("sidebar.activity"), systemImage: appStore.activityStore.snapshot.isRunning ? "hourglass" : "clock") + .tag("activity") Section(L10n.string("sidebar.devices")) { ForEach(appStore.deviceRegistry.profiles) { profile in DeviceSidebarRow( profile: profile, - summary: appStore.dashboardSummary(for: profile) + summary: appStore.dashboardSummary(for: profile), + lastSeenText: appStore.discoveryMonitor.lastSeenText(for: profile) ) .tag("device:\(profile.id)") } @@ -224,7 +233,12 @@ public struct ContentView: View { @ViewBuilder private var detail: some View { - if appStore.showingAddDevice { + if appStore.showingActivity { + ActivityDetailView( + activityStore: appStore.activityStore, + registry: appStore.deviceRegistry + ) + } else if appStore.showingAddDevice { AddDeviceView(store: addDeviceStore) } else if let profile = appStore.selectedProfile { DeviceDashboardView( @@ -236,7 +250,13 @@ public struct ContentView: View { } ) } else { - DeviceListOverviewView(appStore: appStore) + DeviceListOverviewView( + appStore: appStore, + addDiscoveredDevice: { device in + addDeviceStore.stageDiscoveredDevices(appStore.discoveryMonitor.devices, selected: device) + appStore.showAddDevice() + } + ) } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift index 417ea363..f416d1d6 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift @@ -2,18 +2,36 @@ import SwiftUI struct DeviceListOverviewView: View { @ObservedObject var appStore: AppStore + let addDiscoveredDevice: (DiscoveredDevice) -> Void var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + savedDevicesSection + discoverySection + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + @ViewBuilder + private var savedDevicesSection: some View { VStack(alignment: .leading, spacing: 16) { - Text(appStore.deviceRegistry.profiles.isEmpty ? L10n.string("overview.empty.title") : L10n.string("sidebar.all_time_capsules")) - .font(.title2.weight(.semibold)) + Text(appStore.deviceRegistry.profiles.isEmpty + ? L10n.string("overview.empty.title") + : L10n.string("sidebar.all_time_capsules")) + .font(.title2.weight(.semibold)) + if appStore.deviceRegistry.profiles.isEmpty { - Text(L10n.string("overview.empty.message")) - .foregroundStyle(.secondary) - Button { - appStore.showAddDevice() - } label: { - Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") + VStack(alignment: .leading, spacing: 10) { + Text(L10n.string("overview.empty.message")) + .foregroundStyle(.secondary) + Button { + appStore.showAddDevice() + } label: { + Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") + } } } else { ForEach(appStore.deviceRegistry.profiles) { profile in @@ -40,7 +58,119 @@ struct DeviceListOverviewView: View { } } } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var discoverySection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(L10n.string("overview.discovery.title")) + .font(.headline) + Spacer() + Text(appStore.discoveryMonitor.state.title) + .font(.caption) + .foregroundStyle(.secondary) + Button { + appStore.discoveryMonitor.refresh() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .disabled(appStore.backend.isRunning) + .help(L10n.string("overview.discovery.refresh")) + } + + discoveryContent + } + } + + @ViewBuilder + private var discoveryContent: some View { + switch appStore.discoveryMonitor.state { + case .idle, .waitingForReadiness: + Text(L10n.string("overview.discovery.waiting")) + .foregroundStyle(.secondary) + case .discovering: + ProgressView(L10n.string("overview.discovery.discovering")) + case .paused: + Text(L10n.string("overview.discovery.paused")) + .foregroundStyle(.secondary) + case .readinessBlocked: + Text(L10n.string("overview.discovery.readiness_blocked")) + .foregroundStyle(.secondary) + case .failed: + VStack(alignment: .leading, spacing: 6) { + Text(appStore.discoveryMonitor.error?.message ?? L10n.string("overview.discovery.failed")) + .foregroundStyle(.red) + Button(L10n.string("overview.discovery.refresh")) { + appStore.discoveryMonitor.refresh() + } + } + case .empty: + Text(L10n.string("overview.discovery.empty")) + .foregroundStyle(.secondary) + case .ready: + let unsaved = appStore.discoveryMonitor.unsavedDevices + let saved = appStore.discoveryMonitor.savedDevices + if unsaved.isEmpty && saved.isEmpty { + Text(L10n.string("overview.discovery.empty")) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 0) { + ForEach(unsaved) { device in + OverviewDiscoveredDeviceRow( + device: device, + statusText: L10n.string("overview.discovery.unsaved"), + actionTitle: L10n.string("overview.discovery.add") + ) { + addDiscoveredDevice(device) + } + Divider() + } + ForEach(saved) { device in + OverviewDiscoveredDeviceRow( + device: device, + statusText: L10n.string("overview.discovery.saved"), + actionTitle: nil, + action: nil + ) + Divider() + } + } + } + } + } +} + +private struct OverviewDiscoveredDeviceRow: View { + let device: DiscoveredDevice + let statusText: String + let actionTitle: String? + let action: (() -> Void)? + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 3) { + Text(device.name) + .font(.body.weight(.medium)) + HStack(spacing: 6) { + Text(device.host) + if !device.discoveryModelText.isEmpty { + Text(device.discoveryModelText) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + if let actionTitle, let action { + Button(actionTitle, action: action) + } + } + .padding(.vertical, 8) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift index 8b84e91d..a3622c9f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift @@ -3,6 +3,7 @@ import SwiftUI struct DeviceSidebarRow: View { let profile: DeviceProfile let summary: DeviceDashboardSummary + var lastSeenText: String? var body: some View { HStack(spacing: 8) { @@ -10,18 +11,42 @@ struct DeviceSidebarRow: View { VStack(alignment: .leading, spacing: 2) { Text(profile.title) .lineLimit(1) - Text(profile.host) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(spacing: 4) { + Text(profile.host) + .lineLimit(1) + if let lastSeenText { + Text("- \(lastSeenText)") + .lineLimit(1) + } + } + .font(.caption2) + .foregroundStyle(.secondary) } Spacer(minLength: 6) + if let compatibilityBadge { + Text(compatibilityBadge) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } Image(systemName: summary.displayStatus.systemImage) .foregroundStyle(statusColor) .help(summary.displayStatus.title) } } + private var compatibilityBadge: String? { + let payloadFamily = profile.payloadFamily?.lowercased() ?? "" + let osRelease = profile.osRelease ?? "" + if payloadFamily.contains("netbsd4") || osRelease.hasPrefix("4.") { + return "NetBSD 4" + } + return nil + } + private var statusColor: Color { switch summary.displayStatus { case .healthy: diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift new file mode 100644 index 00000000..a7f340b2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift @@ -0,0 +1,44 @@ +import Foundation + +enum ActivityProgressTextAnimator { + static let frameInterval: TimeInterval = 0.3 + static let frameCount = 3 + + static func message(_ message: String?, isRunning: Bool, phase: Int) -> String? { + guard shouldAnimate(message, isRunning: isRunning), + let base = animationBase(message) else { + return message + } + return base + String(repeating: ".", count: frameIndex(phase) + 1) + } + + static func shouldAnimate(_ message: String?, isRunning: Bool) -> Bool { + guard isRunning, + let message, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + return true + } + + static func animationIdentity(for snapshot: ActivitySnapshot) -> String? { + shouldAnimate(snapshot.latestMessage, isRunning: snapshot.isRunning) ? snapshot.latestMessage : nil + } + + static func nextPhase(after phase: Int) -> Int { + (frameIndex(phase) + 1) % frameCount + } + + private static func animationBase(_ message: String?) -> String? { + guard let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + let stripped = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return stripped.isEmpty ? nil : stripped + } + + private static func frameIndex(_ phase: Int) -> Int { + ((phase % frameCount) + frameCount) % frameCount + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift index 1ab1fdd0..89a2471b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift @@ -136,6 +136,6 @@ final class ActivityStore: ObservableObject { guard let operation else { return false } - return ["capabilities", "validate-install", "paths"].contains(operation) + return ["capabilities", "validate-install", "paths", "discover"].contains(operation) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift index 9694aa14..2e273ce0 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift @@ -274,6 +274,31 @@ final class AddDeviceFlowStore: ObservableObject { state = .passwordEntry } + func stageDiscoveredDevice(_ device: DiscoveredDevice) { + stageDiscoveredDevices([device], selected: device) + } + + func stageDiscoveredDevices(_ discoveredDevices: [DiscoveredDevice], selected device: DiscoveredDevice) { + if !coordinator.backend.isRunning { + coordinator.backend.clear() + } + entryMode = .discover + var stagedDevices = discoveredDevices + if !stagedDevices.contains(where: { $0.id == device.id }) { + stagedDevices.append(device) + } + devices = stagedDevices + password = "" + savedProfile = nil + error = nil + currentStage = nil + pendingProfileID = nil + pendingDiscoveredDevice = nil + activeOperation = nil + lastProcessedEventCount = 0 + select(device) + } + func reset() { coordinator.backend.clear() devices = [] diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift new file mode 100644 index 00000000..fdcfcafd --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift @@ -0,0 +1,34 @@ +import Foundation + +struct AddDeviceProgressPresentation: Equatable, BlockingProgressPresenting { + let title: String + let message: String + let detail: String? + + init?(state: AddDeviceFlowState, currentStage: OperationStageState?) { + switch state { + case .discovering: + self.title = L10n.string("add_device.progress.discovering.title") + self.message = L10n.string("add_device.progress.discovering.message") + self.detail = nil + case .configuring: + self.title = L10n.string("add_device.progress.configuring.title") + self.message = L10n.string("add_device.progress.configuring.message") + self.detail = currentStage?.description ?? currentStage?.stage + case .savingProfile: + self.title = L10n.string("add_device.progress.saving.title") + self.message = L10n.string("add_device.progress.saving.message") + self.detail = nil + case .idle, + .discoveryEmpty, + .discoveryReady, + .manualEntry, + .passwordEntry, + .saved, + .authFailed, + .unsupported, + .failed: + return nil + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BlockingProgressPresenting.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BlockingProgressPresenting.swift new file mode 100644 index 00000000..fe5d9a04 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BlockingProgressPresenting.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol BlockingProgressPresenting { + var title: String { get } + var message: String { get } + var detail: String? { get } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift index d597e422..a9ab70bb 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift @@ -221,3 +221,18 @@ struct CheckupPresentation: Equatable { return items } } + +struct CheckupProgressPresentation: Equatable, BlockingProgressPresenting { + let title: String + let message: String + let detail: String? + + init?(state: DoctorWorkflowState, currentStage: OperationStageState?) { + guard state == .running else { + return nil + } + self.title = L10n.string("checkup.progress.running.title") + self.message = L10n.string("checkup.progress.running.message") + self.detail = nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift index 9da841fe..0573e89f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift @@ -82,9 +82,7 @@ struct DeviceDashboardHeaderPresentation: Equatable { enum DashboardHealthDomain: String, CaseIterable, Equatable, Identifiable { case connection case runtime - case finderBonjour - case smbAuth - case timeMachine + case checkup var id: String { rawValue } @@ -94,27 +92,8 @@ enum DashboardHealthDomain: String, CaseIterable, Equatable, Identifiable { return L10n.string("dashboard.health.connection") case .runtime: return L10n.string("dashboard.health.runtime") - case .finderBonjour: - return L10n.string("dashboard.health.finder_bonjour") - case .smbAuth: - return L10n.string("dashboard.health.smb_auth") - case .timeMachine: - return L10n.string("dashboard.health.time_machine") - } - } - - fileprivate var doctorDomain: DoctorCheckDomain { - switch self { - case .connection: - return .connection - case .runtime: - return .runtime - case .finderBonjour: - return .finderBonjour - case .smbAuth: - return .smbAuth - case .timeMachine: - return .timeMachine + case .checkup: + return L10n.string("dashboard.health.checkup") } } } @@ -244,14 +223,8 @@ struct DeviceDashboardOverviewPresentation: Equatable { [ DashboardHealthSection(domain: .connection, rows: [connectionRow(for: summary)]), DashboardHealthSection(domain: .runtime, rows: [runtimeRow(for: summary, currentCheckupSummary: currentCheckupSummary)]), - DashboardHealthSection(domain: .finderBonjour, rows: [ - domainRow(domain: .finderBonjour, summary: summary, currentCheckupSummary: currentCheckupSummary) - ]), - DashboardHealthSection(domain: .smbAuth, rows: [ - domainRow(domain: .smbAuth, summary: summary, currentCheckupSummary: currentCheckupSummary) - ]), - DashboardHealthSection(domain: .timeMachine, rows: [ - timeMachineRow(for: summary, currentCheckupSummary: currentCheckupSummary) + DashboardHealthSection(domain: .checkup, rows: [ + checkupRow(summary: summary, currentCheckupSummary: currentCheckupSummary) ]) ] } @@ -362,59 +335,67 @@ struct DeviceDashboardOverviewPresentation: Equatable { ) } - private static func domainRow( - domain: DashboardHealthDomain, + private static func checkupRow( summary: DeviceDashboardSummary, currentCheckupSummary: DoctorSummary? ) -> DashboardHealthRow { - if let signal = checkupSignal(for: domain, summary: currentCheckupSummary) { + if let signal = serviceCheckupSignal(summary: currentCheckupSummary) { let status = dashboardStatus(signal.severity) return DashboardHealthRow( - id: "\(domain.rawValue)-current-checkup", - title: domain.title, + id: "checkup-current", + title: DashboardHealthDomain.checkup.title, detail: signal.countSummary, status: status, action: status == .good ? nil : .viewCheckup ) } + if let hostWarning = summary.hostWarning { + return DashboardHealthRow( + id: "checkup-host-warning", + title: DashboardHealthDomain.checkup.title, + detail: hostWarning.message, + status: .warning + ) + } guard let lastCheckup = summary.profile.lastCheckup else { return DashboardHealthRow( - id: "\(domain.rawValue)-unchecked", - title: domain.title, + id: "checkup-unchecked", + title: DashboardHealthDomain.checkup.title, detail: L10n.string("dashboard.health.unchecked"), status: .unknown, action: .runCheckup ) } return DashboardHealthRow( - id: "\(domain.rawValue)-snapshot", - title: domain.title, + id: "checkup-snapshot", + title: DashboardHealthDomain.checkup.title, detail: lastCheckup.summary, status: snapshotStatus(lastCheckup), action: snapshotStatus(lastCheckup) == .good ? nil : .viewCheckup ) } - private static func timeMachineRow( - for summary: DeviceDashboardSummary, - currentCheckupSummary: DoctorSummary? - ) -> DashboardHealthRow { - if let hostWarning = summary.hostWarning { - return DashboardHealthRow( - id: "time-machine-host-warning", - title: DashboardHealthDomain.timeMachine.title, - detail: hostWarning.message, - status: .warning - ) - } - return domainRow(domain: .timeMachine, summary: summary, currentCheckupSummary: currentCheckupSummary) - } - private static func checkupSignal( - for domain: DashboardHealthDomain, + for domain: DoctorCheckDomain, summary: DoctorSummary? ) -> DoctorDomainSignal? { - DoctorCheckDomainPolicy.signal(for: domain.doctorDomain, summary: summary) + DoctorCheckDomainPolicy.signal(for: domain, summary: summary) + } + + private static func serviceCheckupSignal(summary: DoctorSummary?) -> DoctorDomainSignal? { + let domains: [DoctorCheckDomain] = [.finderBonjour, .smbAuth, .timeMachine] + let signals = domains.compactMap { checkupSignal(for: $0, summary: summary) } + guard !signals.isEmpty else { + return nil + } + return DoctorDomainSignal( + domain: .general, + checks: signals.flatMap(\.checks), + passCount: signals.map(\.passCount).reduce(0, +), + warnCount: signals.map(\.warnCount).reduce(0, +), + failCount: signals.map(\.failCount).reduce(0, +), + infoCount: signals.map(\.infoCount).reduce(0, +) + ) } private static func dashboardStatus(_ severity: DoctorCheckSeverity) -> DashboardHealthStatus { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift index b56802c2..397e36e8 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift @@ -5,6 +5,8 @@ struct DeployOptions: Equatable { let nbnsEnabled: Bool let noReboot: Bool let noWait: Bool + let internalShareUseDiskRoot: Bool + let anyProtocol: Bool let debugLogging: Bool let mountWait: Int } @@ -47,19 +49,25 @@ enum DeployWorkflowState: String, CaseIterable, Equatable, Codable { @MainActor final class DeployWorkflowStore: ObservableObject { @Published var nbnsEnabled = true { - didSet { markPlanStaleIfNeeded() } + didSet { reconcilePlanFreshness() } } @Published var noReboot = false { - didSet { markPlanStaleIfNeeded() } + didSet { reconcilePlanFreshness() } } @Published var noWait = false { - didSet { markPlanStaleIfNeeded() } + didSet { reconcilePlanFreshness() } + } + @Published var internalShareUseDiskRoot = false { + didSet { reconcilePlanFreshness() } + } + @Published var anyProtocol = false { + didSet { reconcilePlanFreshness() } } @Published var debugLogging = false { - didSet { markPlanStaleIfNeeded() } + didSet { reconcilePlanFreshness() } } @Published var mountWait = "30" { - didSet { markPlanStaleIfNeeded() } + didSet { reconcilePlanFreshness() } } @Published private(set) var state: DeployWorkflowState = .idle @@ -140,6 +148,8 @@ final class DeployWorkflowStore: ObservableObject { noReboot: options.noReboot, noWait: options.noWait, nbnsEnabled: options.nbnsEnabled, + internalShareUseDiskRoot: options.internalShareUseDiskRoot, + anyProtocol: options.anyProtocol, debugLogging: options.debugLogging, mountWait: Double(options.mountWait), password: password @@ -187,6 +197,8 @@ final class DeployWorkflowStore: ObservableObject { noReboot: options.noReboot, noWait: options.noWait, nbnsEnabled: options.nbnsEnabled, + internalShareUseDiskRoot: options.internalShareUseDiskRoot, + anyProtocol: options.anyProtocol, debugLogging: options.debugLogging, mountWait: Double(options.mountWait), password: password @@ -232,16 +244,25 @@ final class DeployWorkflowStore: ObservableObject { nbnsEnabled: nbnsEnabled, noReboot: noReboot, noWait: noWait, + internalShareUseDiskRoot: internalShareUseDiskRoot, + anyProtocol: anyProtocol, debugLogging: debugLogging, mountWait: mountWaitValue ) } - private func markPlanStaleIfNeeded() { - guard state == .planReady, currentOptions != plannedOptions else { + private func reconcilePlanFreshness() { + guard plan != nil, state == .planReady || state == .planStale else { return } - state = .planStale + if currentOptions == plannedOptions { + state = .planReady + if error?.code == "plan_stale" { + error = nil + } + } else { + state = .planStale + } } private func process(_ events: [BackendEvent]) { @@ -301,8 +322,9 @@ final class DeployWorkflowStore: ObservableObject { plan = try event.decodePayload(DeployPlanPayload.self) result = nil error = nil - state = .planReady activeOperation = nil + state = .planReady + reconcilePlanFreshness() } catch { failContract(state: .planFailed, error: error) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index 9d985ae9..d2631056 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -16,6 +16,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { let profileEditorStore: DeviceProfileEditorStore private let urlOpener: URLOpening + private let smbAccountResolver: SMBAccountResolving private var activeCheckupOperation: ActiveOperation? private var activeDeployOperation: ActiveOperation? private var cancellables: Set = [] @@ -23,11 +24,13 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { init( profile: DeviceProfile, appStore: AppStore, - urlOpener: URLOpening = WorkspaceURLOpener() + urlOpener: URLOpening = WorkspaceURLOpener(), + smbAccountResolver: SMBAccountResolving = KeychainSMBAccountResolver() ) { self.id = profile.id self.appStore = appStore self.urlOpener = urlOpener + self.smbAccountResolver = smbAccountResolver self.deployStore = DeployWorkflowStore(coordinator: appStore.operationCoordinator) self.doctorStore = DoctorStore(coordinator: appStore.operationCoordinator) self.maintenanceStore = MaintenanceStore(coordinator: appStore.operationCoordinator) @@ -352,10 +355,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } 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 { + guard let url = SMBAddressPolicy.url(for: profile, account: smbAccountResolver.account(for: profile)) else { return } urlOpener.open(url) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift new file mode 100644 index 00000000..509da291 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift @@ -0,0 +1,264 @@ +import Combine +import Foundation + +enum DeviceDiscoveryMonitorState: String, CaseIterable, Equatable { + case idle + case waitingForReadiness + case discovering + case empty + case ready + case paused + case readinessBlocked + case failed + + var title: String { + switch self { + case .idle: + return L10n.string("discovery_monitor.state.idle") + case .waitingForReadiness: + return L10n.string("discovery_monitor.state.waiting_for_readiness") + case .discovering: + return L10n.string("discovery_monitor.state.discovering") + case .empty: + return L10n.string("discovery_monitor.state.empty") + case .ready: + return L10n.string("discovery_monitor.state.ready") + case .paused: + return L10n.string("discovery_monitor.state.paused") + case .readinessBlocked: + return L10n.string("discovery_monitor.state.readiness_blocked") + case .failed: + return L10n.string("discovery_monitor.state.failed") + } + } +} + +@MainActor +final class DeviceDiscoveryMonitorStore: ObservableObject { + @Published private(set) var state: DeviceDiscoveryMonitorState = .idle + @Published private(set) var devices: [DiscoveredDevice] = [] + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let coordinator: OperationCoordinator + let readinessStore: AppReadinessStore + let registry: DeviceRegistryStore + + private let timeout: Double + private var isMonitoring = false + private var pendingRefresh = false + private var activeOperation: ActiveOperation? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + init( + coordinator: OperationCoordinator, + readinessStore: AppReadinessStore, + registry: DeviceRegistryStore, + timeout: Double = 4 + ) { + self.coordinator = coordinator + self.readinessStore = readinessStore + self.registry = registry + self.timeout = timeout + + readinessStore.$state + .sink { [weak self] _ in + Task { @MainActor in + self?.handleReadinessChange() + } + } + .store(in: &cancellables) + coordinator.backend.$isRunning + .sink { [weak self] isRunning in + guard !isRunning else { return } + Task { @MainActor in + self?.resumePendingRefreshIfNeeded() + } + } + .store(in: &cancellables) + coordinator.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + registry.$profiles + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + var unsavedDevices: [DiscoveredDevice] { + devices.filter { matchingProfile(for: $0) == nil } + } + + var savedDevices: [DiscoveredDevice] { + devices.filter { matchingProfile(for: $0) != nil } + } + + func startMonitoring() { + guard !isMonitoring else { + return + } + isMonitoring = true + handleReadinessChange() + } + + func refresh() { + guard isMonitoring else { + isMonitoring = true + return handleReadinessChange() + } + runDiscoverWhenPossible() + } + + func matchingProfile(for device: DiscoveredDevice) -> DeviceProfile? { + registry.matchingProfile(host: device.host, bonjourFullname: device.fullname) + } + + func lastSeenText(for profile: DeviceProfile) -> String? { + guard state == .ready || state == .empty else { + return nil + } + let wasSeen = devices.contains { device in + matchingProfile(for: device)?.id == profile.id + } + return wasSeen ? L10n.string("discovery_monitor.last_seen.now") : nil + } + + private func handleReadinessChange() { + guard isMonitoring else { + return + } + switch readinessStore.state.kind { + case .ready, .degraded: + if devices.isEmpty && state != .discovering { + runDiscoverWhenPossible() + } + case .blocked: + state = .readinessBlocked + pendingRefresh = false + default: + state = .waitingForReadiness + pendingRefresh = false + } + } + + private func runDiscoverWhenPossible() { + switch readinessStore.state.kind { + case .ready, .degraded: + break + case .blocked: + state = .readinessBlocked + pendingRefresh = false + return + default: + state = .waitingForReadiness + pendingRefresh = false + return + } + + guard !coordinator.backend.isRunning else { + if activeOperation == nil { + pendingRefresh = true + state = .paused + } + return + } + + coordinator.clear() + lastProcessedEventCount = 0 + error = nil + currentStage = nil + switch coordinator.run(operation: "discover", params: OperationParams.discover(timeout: timeout), profile: nil) { + case .started(let operation): + activeOperation = operation + state = .discovering + case .rejected(let message): + activeOperation = nil + error = BackendErrorViewModel( + operation: "discover", + code: "operation_rejected", + message: message + ) + state = .failed + } + } + + private func resumePendingRefreshIfNeeded() { + guard pendingRefresh else { + return + } + pendingRefresh = false + runDiscoverWhenPossible() + } + + private func process(_ events: [BackendEvent]) { + 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" else { + return + } + guard activeOperation?.operation == event.operation else { + return + } + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + if event.type == "error" { + error = BackendErrorViewModel(event: event) + activeOperation = nil + state = .failed + return + } + guard event.type == "result" else { + return + } + guard event.ok == true else { + error = BackendErrorViewModel( + operation: "discover", + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + activeOperation = nil + state = .failed + return + } + applyDiscoverResult(event) + } + + private func applyDiscoverResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(DiscoverPayload.self) + devices = payload.devices.enumerated().map { index, device in + DiscoveredDevice(payload: device, index: index) + } + error = nil + activeOperation = nil + state = devices.isEmpty ? .empty : .ready + } catch { + self.error = BackendErrorViewModel( + operation: "discover", + code: "contract_decode_failed", + message: error.localizedDescription + ) + activeOperation = nil + state = .failed + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift index 571e909f..ea2a8f58 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift @@ -147,6 +147,30 @@ struct InstallCompletionPresentation: Equatable { } } +struct InstallProgressPresentation: Equatable, BlockingProgressPresenting { + let title: String + let message: String + let detail: String? + + init?(state: DeployWorkflowState, currentStage: OperationStageState?) { + switch state { + case .deploying: + self.title = L10n.string("install.progress.deploying.title") + self.message = L10n.string("install.progress.deploying.message") + case .idle, + .planning, + .planReady, + .planStale, + .planFailed, + .awaitingConfirmation, + .deployed, + .deployFailed: + return nil + } + self.detail = currentStage?.description ?? currentStage?.stage + } +} + struct InstallWorkflowPresentation: Equatable { let title: String let stateTitle: String diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift new file mode 100644 index 00000000..8c716157 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class ActivityProgressTextAnimatorTests: XCTestCase { + func testRunningMessageCyclesOneTwoAndThreeDots() { + let message = "Run local and remote diagnostic checks." + + XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: true, phase: 0), "Run local and remote diagnostic checks.") + XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: true, phase: 1), "Run local and remote diagnostic checks..") + XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: true, phase: 2), "Run local and remote diagnostic checks...") + XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: true, phase: 3), "Run local and remote diagnostic checks.") + } + + func testRunningMessageNormalizesExistingDotsBeforeAnimating() { + XCTAssertEqual(ActivityProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 0), "Resolve target.") + XCTAssertEqual(ActivityProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 1), "Resolve target..") + XCTAssertEqual(ActivityProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 2), "Resolve target...") + } + + func testInactiveMessagesRemainStable() { + let message = "deployment completed." + + XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: false, phase: 0), message) + XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: false, phase: 2), message) + } + + func testEmptyMessagesDoNotAnimate() { + XCTAssertNil(ActivityProgressTextAnimator.message(nil, isRunning: true, phase: 1)) + XCTAssertEqual(ActivityProgressTextAnimator.message("", isRunning: true, phase: 1), "") + XCTAssertEqual(ActivityProgressTextAnimator.message(" ", isRunning: true, phase: 1), " ") + } + + func testAnimationIdentityExistsOnlyForActiveMessages() { + let running = ActivitySnapshot( + isRunning: true, + scope: .app, + operationTitle: "Checkup", + latestMessage: "Run local and remote diagnostic checks.", + timeline: [] + ) + let completed = ActivitySnapshot( + isRunning: false, + scope: .app, + operationTitle: "Checkup", + latestMessage: "Run local and remote diagnostic checks.", + timeline: [] + ) + + XCTAssertEqual(ActivityProgressTextAnimator.animationIdentity(for: running), "Run local and remote diagnostic checks.") + XCTAssertNil(ActivityProgressTextAnimator.animationIdentity(for: completed)) + } + + func testFrameIntervalMatchesBottomBarAnimationCadence() { + XCTAssertEqual(ActivityProgressTextAnimator.frameInterval, 0.3) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift index 9d1d337e..d9f9e6b3 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift @@ -69,6 +69,34 @@ final class ActivityStoreTests: XCTestCase { XCTAssertEqual(activity.snapshot.operationTitle, "App Readiness") } + func testActivitySnapshotTracksDiscoveryAsAppScoped() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "discover", + stage: "bonjour_discovery", + description: "Browse for AirPort Bonjour services." + ), + BackendEvent( + type: "result", + operation: "discover", + ok: true, + payload: testDiscoverPayload(records: []) + ) + ], delayNanoseconds: 80_000_000) + ]) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let activity = ActivityStore(coordinator: coordinator) + + backend.run(operation: "discover") + + try await waitUntilStoreState { activity.snapshot.isRunning } + XCTAssertEqual(activity.snapshot.operationTitle, "Discovery") + XCTAssertEqual(activity.snapshot.scope, .app) + } + func testSuccessfulAppValidationPresentsAppReadyWithoutDetailMessage() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift index f88b703d..5b409339 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -495,6 +495,50 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls.count, 1) } + func testStagingDiscoveredDeviceFromOverviewPromptsForPasswordWithoutRunningDiscovery() async throws { + let fixture = try await makeStore(responses: []) + let payload = try testDiscoveredDevice( + name: "Office Capsule", + host: "10.0.0.2", + model: "TimeCapsule6,116" + ).decode(DiscoveredDevicePayload.self) + let device = DiscoveredDevice(payload: payload, index: 0) + + fixture.store.stageDiscoveredDevice(device) + + XCTAssertEqual(fixture.store.state, .passwordEntry) + XCTAssertEqual(fixture.store.devices, [device]) + XCTAssertEqual(fixture.store.selectedDeviceID, device.id) + XCTAssertEqual(fixture.store.hostFieldText, "10.0.0.2") + XCTAssertEqual(fixture.runner.calls, []) + } + + func testStagingDiscoveredDevicesFromOverviewKeepsListOrderAndPreselectsClickedDevice() async throws { + let fixture = try await makeStore(responses: []) + let first = DiscoveredDevice(payload: try testDiscoveredDevice( + id: "bonjour:first", + name: "First Capsule", + host: "10.0.0.2", + hostname: "first.local.", + fullname: "First Capsule._airport._tcp.local." + ).decode(DiscoveredDevicePayload.self), index: 0) + let second = DiscoveredDevice(payload: try testDiscoveredDevice( + id: "bonjour:second", + name: "Second Capsule", + host: "10.0.0.3", + hostname: "second.local.", + fullname: "Second Capsule._airport._tcp.local." + ).decode(DiscoveredDevicePayload.self), index: 1) + + fixture.store.stageDiscoveredDevices([first, second], selected: second) + + XCTAssertEqual(fixture.store.state, .passwordEntry) + XCTAssertEqual(fixture.store.devices, [first, second]) + XCTAssertEqual(fixture.store.selectedDeviceID, second.id) + XCTAssertEqual(fixture.store.hostFieldText, "10.0.0.3") + XCTAssertEqual(fixture.runner.calls, []) + } + private func makeStore(responses: [StoreTestRunner.Response]) async throws -> ( store: AddDeviceFlowStore, runner: StoreTestRunner, diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDevicePresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDevicePresentationTests.swift new file mode 100644 index 00000000..5205c15a --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDevicePresentationTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class AddDevicePresentationTests: XCTestCase { + func testProgressPresentationAppearsOnlyForBlockingStates() { + let discoveryStage = OperationStageState(event: BackendEvent( + type: "stage", + operation: "discover", + stage: "browse_bonjour", + cancellable: true, + description: "Browsing Bonjour services." + )) + let configureStage = OperationStageState(event: BackendEvent( + type: "stage", + operation: "configure", + stage: "ssh_probe", + cancellable: true, + description: "Checking SSH access." + )) + + let discovering = AddDeviceProgressPresentation(state: .discovering, currentStage: discoveryStage) + XCTAssertEqual(discovering?.title, "Discovering Time Capsules") + XCTAssertEqual(discovering?.message, "Browsing for nearby AirPort Bonjour services...") + XCTAssertNil(discovering?.detail) + + let configuring = AddDeviceProgressPresentation(state: .configuring, currentStage: configureStage) + XCTAssertEqual(configuring?.title, "Connecting to Time Capsule") + XCTAssertEqual(configuring?.detail, "Checking SSH access.") + + let saving = AddDeviceProgressPresentation(state: .savingProfile, currentStage: nil) + XCTAssertEqual(saving?.title, "Saving Device") + XCTAssertNil(saving?.detail) + + for state in AddDeviceFlowState.allCases where ![.discovering, .configuring, .savingProfile].contains(state) { + XCTAssertNil(AddDeviceProgressPresentation(state: state, currentStage: discoveryStage), "\(state) should not show a blocking progress modal.") + } + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift index 9d4e19d5..f3b515a4 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift @@ -10,7 +10,10 @@ final class BundleLayoutTests: XCTestCase { [ .helperMissing, .helperNotExecutable, + .pythonRuntimeMissing, + .pythonExecutableMissing, .distributionRootMissing, + .distributionArtifactsMissing, .toolsDirectoryMissing, .installValidationFailed, .helperLaunchFailed, @@ -50,6 +53,30 @@ final class BundleLayoutTests: XCTestCase { XCTAssertTrue(issues.contains(where: { $0.code == .distributionRootMissing && $0.severity == .error })) } + func testMissingDistributionArtifactsIsBlockingIssue() throws { + let layout = try makeLayout(createDistributionBin: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .distributionArtifactsMissing && $0.severity == .error })) + } + + func testMissingPythonRuntimeIsBlockingIssue() throws { + let layout = try makeLayout(createPythonRuntime: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .pythonRuntimeMissing && $0.severity == .error })) + } + + func testMissingPythonExecutableIsBlockingIssue() throws { + let layout = try makeLayout(createPythonExecutable: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .pythonExecutableMissing && $0.severity == .error })) + } + func testMissingToolsDirectoryIsWarningIssue() throws { let layout = try makeLayout(createTools: false) @@ -62,6 +89,9 @@ final class BundleLayoutTests: XCTestCase { createHelper: Bool = true, helperPermissions: Int = 0o755, createDistribution: Bool = true, + createDistributionBin: Bool = true, + createPythonRuntime: Bool = true, + createPythonExecutable: Bool = true, createTools: Bool = true ) throws -> BundleLayout { let temp = try TemporaryDirectory() @@ -79,10 +109,25 @@ final class BundleLayoutTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: helperPermissions], ofItemAtPath: helper.path) } if createDistribution { - try FileManager.default.createDirectory( - at: resources.appendingPathComponent("Distribution", isDirectory: true), - withIntermediateDirectories: true - ) + let distribution = resources.appendingPathComponent("Distribution", isDirectory: true) + try FileManager.default.createDirectory(at: distribution, withIntermediateDirectories: true) + if createDistributionBin { + try FileManager.default.createDirectory( + at: distribution.appendingPathComponent("bin", isDirectory: true), + withIntermediateDirectories: true + ) + } + } + if createPythonRuntime { + let pythonBin = resources + .appendingPathComponent("Python", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: pythonBin, withIntermediateDirectories: true) + if createPythonExecutable { + let python = pythonBin.appendingPathComponent("python") + try "#!/bin/sh\nexit 0\n".write(to: python, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: python.path) + } } if createTools { try FileManager.default.createDirectory( diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift index fc176535..1640c260 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -103,7 +103,7 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(connection.action, .replacePassword) } - func testOverviewPresentationUsesTypedCheckupDomainsForHealthRows() throws { + func testOverviewPresentationAggregatesServiceCheckupDomainsForHealthRow() throws { var profile = try makeProfile() profile.lastDeploy = DeviceDeploySnapshot( deployedAt: Date(timeIntervalSince1970: 100), @@ -130,9 +130,10 @@ final class DashboardPresentationTests: XCTestCase { let presentation = DeviceDashboardOverviewPresentation(summary: summary, currentCheckupSummary: checkup) XCTAssertEqual(try row(.runtime, in: presentation).status, .good) - XCTAssertEqual(try row(.finderBonjour, in: presentation).status, .warning) - XCTAssertEqual(try row(.smbAuth, in: presentation).status, .failed) - XCTAssertEqual(try row(.timeMachine, in: presentation).status, .good) + XCTAssertEqual(presentation.healthSections.map(\.domain), [.connection, .runtime, .checkup]) + XCTAssertEqual(try row(.checkup, in: presentation).status, .failed) + XCTAssertEqual(try row(.checkup, in: presentation).detail, "PASS 1, WARN 1, FAIL 1") + XCTAssertEqual(try row(.checkup, in: presentation).action, .viewCheckup) } func testOverviewPresentationCoversInstallHealthyActivationAndHostWarningStates() throws { @@ -194,8 +195,8 @@ final class DashboardPresentationTests: XCTestCase { primaryAction: .openSMB, hostWarning: warning )) - XCTAssertEqual(try row(.timeMachine, in: hostWarning).status, .warning) - XCTAssertEqual(try row(.timeMachine, in: hostWarning).detail, "Time Machine warning.") + XCTAssertEqual(try row(.checkup, in: hostWarning).status, .warning) + XCTAssertEqual(try row(.checkup, in: hostWarning).detail, "Time Machine warning.") } func testInstallPlanPresentationShowsDeviceImpactAndWarnings() throws { @@ -256,7 +257,7 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(presentation.title, "Install / Update Verified") XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Verified", value: "yes"))) XCTAssertEqual(presentation.warnings, [ - "NetBSD4 devices may need Start SMB after a later reboot unless the boot hook is patched." + "NetBSD4 devices may need Activate after a later reboot unless the boot hook is patched." ]) XCTAssertEqual(presentation.actions, [.openFinder, .runCheckup, .viewDiagnostics]) } @@ -271,6 +272,42 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(presentation.items.first?.title, "Uploading") } + func testInstallProgressPresentationAppearsOnlyWhileDeploying() { + let stage = OperationStageState(event: BackendEvent( + type: "stage", + operation: "deploy", + stage: "upload_payload", + description: "Uploading files." + )) + + let deploying = InstallProgressPresentation(state: .deploying, currentStage: stage) + + XCTAssertEqual(deploying?.title, "Installing / Updating SMB") + XCTAssertEqual(deploying?.message, "Uploading and applying the managed SMB runtime. This can take a few seconds.") + XCTAssertEqual(deploying?.detail, "Uploading files.") + for state in DeployWorkflowState.allCases where state != .deploying { + XCTAssertNil(InstallProgressPresentation(state: state, currentStage: stage), "\(state) should not show a blocking progress modal.") + } + } + + func testCheckupProgressPresentationAppearsOnlyWhileRunning() { + let stage = OperationStageState(event: BackendEvent( + type: "stage", + operation: "doctor", + stage: "run_checks", + description: "Run local and remote diagnostic checks." + )) + + let running = CheckupProgressPresentation(state: .running, currentStage: stage) + + XCTAssertEqual(running?.title, "Running Checkup") + XCTAssertEqual(running?.message, "Running local and remote diagnostic checks.\nThis can take a few minutes...") + XCTAssertNil(running?.detail) + for state in DoctorWorkflowState.allCases where state != .running { + XCTAssertNil(CheckupProgressPresentation(state: state, currentStage: stage), "\(state) should not show a blocking progress modal.") + } + } + func testMaintenanceActionPolicyCoversAllStates() { let expectedActivate: [MaintenanceOperationState: MaintenanceUserAction] = [ .idle: .planActivation, @@ -312,6 +349,8 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(primaryAction(.repairXattrs, state: .scanReady, canRepairXattrs: false), .scanMetadata) XCTAssertEqual(MaintenanceActionPolicy.secondaryActions(workflow: .fsck, state: .planReady), [.planFsck, .findVolumes]) XCTAssertEqual(MaintenanceActionPolicy.secondaryActions(workflow: .repairXattrs, state: .scanReady), [.scanMetadata]) + XCTAssertEqual(MaintenanceUserAction.planActivation.title, "Plan Activate") + XCTAssertEqual(MaintenanceUserAction.runActivation.title, "Activate") } func testMaintenanceStatusMessagesCoverAllStates() { @@ -354,13 +393,13 @@ final class DashboardPresentationTests: XCTestCase { var presentation = MaintenanceDashboardPresentation(store: store, profile: profile) XCTAssertEqual(presentation.detail.workflow, .activate) XCTAssertEqual(presentation.detail.primaryAction, .runActivation) - XCTAssertEqual(presentation.detail.plan?.title, "Start SMB Plan") + XCTAssertEqual(presentation.detail.plan?.title, "Activation Plan") XCTAssertEqual(presentation.detail.plan?.rows.first, PresentationRow(label: "Device", value: profile.title)) store.runActivation(password: "pw") try await waitUntilStoreState { store.activateState == .succeeded && !store.isRunning } presentation = MaintenanceDashboardPresentation(store: store, profile: profile) - XCTAssertEqual(presentation.detail.completion?.title, "Start SMB Complete") + XCTAssertEqual(presentation.detail.completion?.title, "Activation Complete") XCTAssertTrue(presentation.detail.completion?.rows.contains(PresentationRow(label: "Already Active", value: "yes")) == true) store.planUninstall(password: "pw") diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index f5baa940..e7f3297f 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -103,6 +103,35 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(opener.openedURLs.map(\.absoluteString), ["smb://10.0.0.2"]) } + func testOpenSMBPrimaryActionUsesBonjourHostnameWhenAvailable() async throws { + let fixture = try await makeFixture(responses: []) + let discovered = DiscoveredDevice( + payload: try testDiscoveredDevice( + host: "10.0.0.2", + hostname: "office-capsule.local.", + fullname: "Office Capsule._airport._tcp.local." + ).decode(DiscoveredDevicePayload.self), + index: 0 + ) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: discovered, + passwordState: .available, + preferredID: "device-one" + ) + let opener = RecordingURLOpener() + let session = DeviceDashboardSession( + profile: profile, + appStore: fixture.appStore, + urlOpener: opener, + smbAccountResolver: StaticSMBAccountResolver(accounts: [profile.id: "jameschang"]) + ) + + session.performPrimaryAction(.openSMB, profile: profile) + + XCTAssertEqual(opener.openedURLs.map(\.absoluteString), ["smb://jameschang@Office%20Capsule._smb._tcp.local"]) + } + func testPasswordReplacementSaveUpdatesPasswordStateAndHidesEditor() async throws { let fixture = try await makeFixture(responses: []) let profile = try await fixture.registry.saveConfiguredDevice( @@ -499,7 +528,7 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(session.maintenanceStore.selectedWorkflow, .repairXattrs) XCTAssertTrue(session.handleRecoveryAction( - RecoveryAction(title: "Start SMB", kind: .startSMB), + RecoveryAction(title: "Activate", kind: .startSMB), error: error, profile: profile )) @@ -701,3 +730,11 @@ private final class RecordingURLOpener: URLOpening { openedURLs.append(url) } } + +private struct StaticSMBAccountResolver: SMBAccountResolving { + let accounts: [DeviceProfile.ID: String] + + func account(for profile: DeviceProfile) -> String? { + accounts[profile.id] + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift index fec09354..505f06d8 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift @@ -41,6 +41,8 @@ final class DeployWorkflowStoreTests: XCTestCase { store.noReboot = true store.noWait = true store.nbnsEnabled = false + store.internalShareUseDiskRoot = true + store.anyProtocol = true store.debugLogging = true store.runPlan(password: "pw") @@ -55,6 +57,8 @@ final class DeployWorkflowStoreTests: XCTestCase { 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["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["any_protocol"], .bool(true)) XCTAssertEqual(runner.calls[0].params["debug_logging"], .bool(true)) XCTAssertEqual(runner.calls[0].params["mount_wait"], .number(45)) XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) @@ -117,10 +121,59 @@ final class DeployWorkflowStoreTests: XCTestCase { store.runPlan(password: "pw") try await waitUntilStoreState { store.state == .planReady } - store.noWait = true + store.internalShareUseDiskRoot = true XCTAssertEqual(store.state, .planStale) XCTAssertFalse(store.canDeploy) + + store.internalShareUseDiskRoot = false + + XCTAssertEqual(store.state, .planReady) + XCTAssertTrue(store.canDeploy) + } + + func testOptionChangeWhilePlanningMakesReturnedPlanStaleAndAllowsRegeneration() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ], delayNanoseconds: 50_000_000), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { runner.calls.count == 1 } + XCTAssertEqual(store.state, .planning) + + store.noWait = true + + try await waitUntilStoreState { store.state == .planStale } + XCTAssertFalse(store.canDeploy) + XCTAssertNotNil(store.plan) + XCTAssertEqual(runner.calls[0].params["no_wait"], .bool(false)) + + store.runPlan(password: "pw") + + try await waitUntilStoreState { store.state == .planReady && runner.calls.count == 2 } + XCTAssertTrue(store.canDeploy) + XCTAssertEqual(runner.calls[1].params["no_wait"], .bool(true)) + } + + func testDefaultRuntimeOverridesAreOmittedFromPlanParams() 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 } + + XCTAssertNil(runner.calls[0].params["internal_share_use_disk_root"]) + XCTAssertNil(runner.calls[0].params["any_protocol"]) } func testDeploySendsRunParamsFromPlanOptionsAndStoresResult() async throws { @@ -135,6 +188,8 @@ final class DeployWorkflowStoreTests: XCTestCase { ]) let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) store.mountWait = "30" + store.internalShareUseDiskRoot = true + store.anyProtocol = true store.runPlan(password: "pw") try await waitUntilStoreState { store.state == .planReady } @@ -147,6 +202,8 @@ final class DeployWorkflowStoreTests: XCTestCase { XCTAssertEqual(runner.calls.count, 2) XCTAssertEqual(runner.calls[1].params["dry_run"], .bool(false)) XCTAssertEqual(runner.calls[1].params["mount_wait"], .number(30)) + XCTAssertEqual(runner.calls[1].params["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(runner.calls[1].params["any_protocol"], .bool(true)) XCTAssertEqual(runner.calls[1].params["credentials"], .object(["password": .string("pw2")])) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryMonitorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryMonitorStoreTests.swift new file mode 100644 index 00000000..51ddbd8a --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryMonitorStoreTests.swift @@ -0,0 +1,221 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceDiscoveryMonitorStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual( + DeviceDiscoveryMonitorState.allCases, + [.idle, .waitingForReadiness, .discovering, .empty, .ready, .paused, .readinessBlocked, .failed] + ) + } + + func testWaitsForReadinessThenDiscoversWithoutSavingProfiles() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload())]), + .init(events: [BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload())]), + .init(events: [ + BackendEvent(type: "stage", operation: "discover", stage: "bonjour_discovery"), + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord() + ])) + ]) + ]) + + fixture.monitor.startMonitoring() + XCTAssertEqual(fixture.monitor.state, .waitingForReadiness) + fixture.readiness.start() + + try await waitUntilStoreState { fixture.monitor.state == .ready } + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["capabilities", "validate-install", "discover"]) + XCTAssertEqual(fixture.monitor.devices.map(\.host), ["10.0.0.2"]) + XCTAssertEqual(fixture.monitor.unsavedDevices.count, 1) + XCTAssertTrue(fixture.registry.profiles.isEmpty) + XCTAssertEqual(fixture.monitor.currentStage?.stage, "bonjour_discovery") + } + + func testDiscoveryEmptyFailedAndMalformedPayloadStatesAreExplicit() async throws { + let empty = try await makeReadyFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: []))]) + ]) + empty.monitor.startMonitoring() + try await waitUntilStoreState { empty.monitor.state == .empty } + XCTAssertEqual(empty.monitor.devices, []) + + let failed = try await makeReadyFixture(responses: [ + .init(events: [BackendEvent.error(operation: "discover", code: "bonjour_failed", message: "Bonjour failed.")]) + ]) + failed.monitor.startMonitoring() + try await waitUntilStoreState { failed.monitor.state == .failed } + XCTAssertEqual(failed.monitor.error?.code, "bonjour_failed") + + let malformed = try await makeReadyFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: .object(["schema_version": .string("wrong")]))]) + ]) + malformed.monitor.startMonitoring() + try await waitUntilStoreState { malformed.monitor.state == .failed } + XCTAssertEqual(malformed.monitor.error?.code, "contract_decode_failed") + } + + func testSavedProfilesAreFilteredAndReportedAsSeenNow() async throws { + let fixture = try await makeReadyFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord() + ]))]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + + fixture.monitor.startMonitoring() + + try await waitUntilStoreState { fixture.monitor.state == .ready } + XCTAssertEqual(fixture.monitor.unsavedDevices, []) + XCTAssertEqual(fixture.monitor.savedDevices.map(\.host), ["10.0.0.2"]) + XCTAssertEqual(fixture.monitor.lastSeenText(for: profile), "Seen now") + } + + func testRefreshPausesBehindActiveOperationAndResumesWhenRunnerIsFree() async throws { + let fixture = try await makeReadyFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ok", domain: "Runtime") + ])) + ], delayNanoseconds: 150_000_000), + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord(hostname: "paused.local.") + ]))]) + ]) + + fixture.coordinator.run(operation: "doctor", params: [:], profile: nil) + fixture.monitor.startMonitoring() + + XCTAssertEqual(fixture.monitor.state, .paused) + try await waitUntilStoreState { fixture.monitor.state == .ready } + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["capabilities", "validate-install", "doctor", "discover"]) + } + + func testReadinessBlockedPreventsDiscovery() async throws { + let temp = try TemporaryDirectory() + let runner = StoreTestRunner(responses: []) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let readiness = AppReadinessStore( + backend: backend, + runtimeResolver: DiscoveryMonitorTestRuntimeResolver(issues: [ + BundleRuntimeIssue( + code: .distributionRootMissing, + severity: .error, + message: "missing distribution", + recovery: "reinstall" + ) + ]), + helperPathProvider: { "" } + ) + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let monitor = DeviceDiscoveryMonitorStore(coordinator: coordinator, readinessStore: readiness, registry: registry) + + monitor.startMonitoring() + readiness.start() + + try await waitUntilStoreState { monitor.state == .readinessBlocked } + XCTAssertEqual(monitor.state, .readinessBlocked) + XCTAssertEqual(runner.calls, []) + } + + private struct Fixture { + let runner: StoreTestRunner + let coordinator: OperationCoordinator + let readiness: AppReadinessStore + let registry: DeviceRegistryStore + let monitor: DeviceDiscoveryMonitorStore + } + + private func makeFixture(responses: [StoreTestRunner.Response]) async throws -> Fixture { + let temp = try TemporaryDirectory() + let runner = StoreTestRunner(responses: responses) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let readiness = AppReadinessStore( + backend: backend, + runtimeResolver: DiscoveryMonitorTestRuntimeResolver(), + helperPathProvider: { "" } + ) + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let monitor = DeviceDiscoveryMonitorStore(coordinator: coordinator, readinessStore: readiness, registry: registry) + return Fixture( + runner: runner, + coordinator: coordinator, + readiness: readiness, + registry: registry, + monitor: monitor + ) + } + + private func makeReadyFixture(responses: [StoreTestRunner.Response]) async throws -> Fixture { + let fixture = try await makeFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload())]), + .init(events: [BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload())]) + ] + responses) + fixture.readiness.start() + try await waitUntilStoreState { fixture.readiness.state.kind == .ready } + return fixture + } + + private func capabilitiesPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "api_schema_version": .number(1), + "helper_version": .string("1.2.3"), + "helper_version_code": .number(123), + "operations": .array([.string("discover"), .string("validate-install")]), + "distribution_root": .string("/bundle/Distribution"), + "artifact_manifest_sha256": .string("abc"), + "confirmation_schema_version": .number(1), + "summary": .string("helper capabilities resolved.") + ]) + } + + private func validationPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "ok": .bool(true), + "checks": .array([ + .object([ + "id": .string("distribution_root"), + "ok": .bool(true), + "message": .string("distribution root is valid") + ]) + ]), + "counts": .object([ + "checks": .number(1), + "pass": .number(1), + "fail": .number(0) + ]), + "summary": .string("install validation passed.") + ]) + } +} + +private struct DiscoveryMonitorTestRuntimeResolver: AppRuntimeResolving { + var issues: [BundleRuntimeIssue] = [] + + func resolve(helperPath: String?) throws -> HelperResolution { + HelperResolution( + executableURL: URL(fileURLWithPath: "/bundle/Contents/Helpers/tcapsule"), + distributionRootURL: URL(fileURLWithPath: "/bundle/Contents/Resources/Distribution", isDirectory: true), + toolsBinURL: URL(fileURLWithPath: "/bundle/Contents/Resources/Tools/bin", isDirectory: true), + mode: .productionBundle, + attemptedPaths: ["/bundle/Contents/Helpers/tcapsule"] + ) + } + + func runtimeIssues(for resolution: HelperResolution) -> [BundleRuntimeIssue] { + issues + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift index c34ed9be..b7992e71 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift @@ -158,9 +158,11 @@ final class HelperLocatorTests: XCTestCase { let macOS = contents.appendingPathComponent("MacOS", isDirectory: true) let resources = contents.appendingPathComponent("Resources", isDirectory: true) let helpers = contents.appendingPathComponent("Helpers", isDirectory: true) + let pythonBin = resources.appendingPathComponent("Python/bin", isDirectory: true) try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: helpers, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: pythonBin, withIntermediateDirectories: true) try """ @@ -177,8 +179,13 @@ final class HelperLocatorTests: XCTestCase { """.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 "#!/bin/sh\nexit 0\n".write(to: pythonBin.appendingPathComponent("python"), 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) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: pythonBin.appendingPathComponent("python").path) + try FileManager.default.createDirectory( + at: resources.appendingPathComponent("Distribution/bin", isDirectory: true), + withIntermediateDirectories: true + ) if createTools { try FileManager.default.createDirectory(at: resources.appendingPathComponent("Tools/bin", isDirectory: true), withIntermediateDirectories: true) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index d20ab20f..e649a36e 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -39,9 +39,30 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(params["debug_logging"], .bool(true)) XCTAssertEqual(params["mount_wait"], .number(45)) XCTAssertEqual(params["no_wait"], .bool(true)) + XCTAssertNil(params["internal_share_use_disk_root"]) + XCTAssertNil(params["any_protocol"]) XCTAssertNil(params["credentials"]) } + func testDeployPlanParamsCarryAdvancedRuntimeOverridesWhenEnabled() { + let params = OperationParams.deployPlan( + noReboot: false, + noWait: false, + nbnsEnabled: true, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: false, + mountWait: 30, + password: "pw" + ) + + XCTAssertEqual(params["dry_run"], .bool(true)) + XCTAssertEqual(params["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(params["any_protocol"], .bool(true)) + XCTAssertEqual(params["debug_logging"], .bool(false)) + XCTAssertEqual(params["credentials"], .object(["password": .string("pw")])) + } + func testConfigureParamsUseSelectedRecordInsteadOfManualHostWhenProvided() { let selectedRecord = JSONValue.object([ "name": .string("TC"), diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAccountResolverTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAccountResolverTests.swift new file mode 100644 index 00000000..8e55dc26 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAccountResolverTests.swift @@ -0,0 +1,118 @@ +import Security +import XCTest +@testable import TimeCapsuleSMBApp + +final class SMBAccountResolverTests: XCTestCase { + func testFindsSMBAccountForResolvedHostnameWithoutReadingPasswordData() { + let keychain = AccountLookupKeychainClient(accountsByServer: [ + "AirPort-Time-Capsule.local": "jameschang" + ]) + let resolver = KeychainSMBAccountResolver(keychainClient: keychain) + let profile = makeProfile( + host: "root@192.168.1.72", + bonjourName: "AirPort Time Capsule", + hostname: "AirPort-Time-Capsule.local." + ) + + XCTAssertEqual(resolver.account(for: profile), "jameschang") + XCTAssertEqual(keychain.queries.count, 1) + XCTAssertEqual(keychain.queries[0][kSecClass as String] as? String, kSecClassInternetPassword as String) + XCTAssertEqual(keychain.queries[0][kSecAttrProtocol as String] as? String, kSecAttrProtocolSMB as String) + XCTAssertEqual(keychain.queries[0][kSecReturnAttributes as String] as? Bool, true) + XCTAssertNil(keychain.queries[0][kSecReturnData as String]) + } + + func testFallsBackToLowercaseServerCandidate() { + let keychain = AccountLookupKeychainClient(accountsByServer: [ + "jamess-airport-time-capsule.local": "admin" + ]) + let resolver = KeychainSMBAccountResolver(keychainClient: keychain) + let profile = makeProfile( + host: "root@192.168.1.217", + bonjourName: "James's AirPort Time Capsule", + hostname: "Jamess-AirPort-Time-Capsule.local." + ) + + XCTAssertEqual(resolver.account(for: profile), "admin") + XCTAssertEqual(keychain.queries.map { $0[kSecAttrServer as String] as? String }, [ + "Jamess-AirPort-Time-Capsule.local", + "192.168.1.217", + "jamess-airport-time-capsule.local" + ]) + } + + func testReturnsNilWhenNoSMBAccountExists() { + let keychain = AccountLookupKeychainClient(accountsByServer: [:]) + let resolver = KeychainSMBAccountResolver(keychainClient: keychain) + let profile = makeProfile(host: "root@10.0.0.2", bonjourName: nil, hostname: nil) + + XCTAssertNil(resolver.account(for: profile)) + } + + private func makeProfile( + host: String, + bonjourName: String?, + hostname: String? + ) -> DeviceProfile { + DeviceProfile( + id: "device-one", + displayName: bonjourName ?? "Office Capsule", + host: host, + bonjourName: bonjourName, + bonjourFullname: bonjourName.map { "\($0)._airport._tcp.local." }, + hostname: hostname, + addresses: [], + syap: nil, + model: nil, + osName: nil, + osRelease: nil, + arch: nil, + elfEndianness: nil, + payloadFamily: nil, + deviceGeneration: nil, + configPath: "/tmp/device-one/.env", + keychainAccount: "device-one", + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + lastCheckup: nil, + lastDeploy: nil, + settings: .default, + passwordState: .available + ) + } +} + +private final class AccountLookupKeychainClient: KeychainClient { + let accountsByServer: [String: String] + private(set) var queries: [[String: Any]] = [] + + init(accountsByServer: [String: String]) { + self.accountsByServer = accountsByServer + } + + func copyMatching(_ query: [String: Any], result: inout CFTypeRef?) -> OSStatus { + queries.append(query) + guard let server = query[kSecAttrServer as String] as? String, + let account = accountsByServer[server] else { + return errSecItemNotFound + } + result = [kSecAttrAccount as String: account] as CFDictionary + return errSecSuccess + } + + func add(_ query: [String: Any]) -> OSStatus { + errSecSuccess + } + + func update(_ query: [String: Any], attributes: [String: Any]) -> OSStatus { + errSecSuccess + } + + func delete(_ query: [String: Any]) -> OSStatus { + errSecSuccess + } + + func message(for status: OSStatus) -> String? { + "status \(status)" + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift new file mode 100644 index 00000000..d0b09195 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class SMBAddressPolicyTests: XCTestCase { + func testPrefersBonjourSMBServiceOverResolvedHostname() { + let profile = makeProfile( + host: "root@10.0.0.2", + bonjourName: "AirPort Time Capsule", + bonjourFullname: "AirPort Time Capsule._airport._tcp.local.", + hostname: "AirPort-Time-Capsule.local." + ) + + XCTAssertEqual(SMBAddressPolicy.preferredHost(for: profile), "AirPort Time Capsule._smb._tcp.local") + XCTAssertEqual( + SMBAddressPolicy.url(for: profile, account: "James Chang")?.absoluteString, + "smb://James%20Chang@AirPort%20Time%20Capsule._smb._tcp.local" + ) + } + + func testFallsBackToConfiguredHostWhenBonjourHostnameIsMissing() { + let profile = makeProfile(host: "root@10.0.0.2", bonjourName: nil, bonjourFullname: nil, hostname: nil) + + XCTAssertEqual(SMBAddressPolicy.preferredHost(for: profile), "10.0.0.2") + XCTAssertEqual(SMBAddressPolicy.url(for: profile)?.absoluteString, "smb://10.0.0.2") + } + + func testTrimsURLPathAndTrailingDotFromHostCandidates() { + let profile = makeProfile( + host: "smb://office-capsule.local./Data", + bonjourName: nil, + bonjourFullname: nil, + hostname: " " + ) + + XCTAssertEqual(SMBAddressPolicy.preferredHost(for: profile), "office-capsule.local") + XCTAssertEqual(SMBAddressPolicy.url(for: profile)?.absoluteString, "smb://office-capsule.local") + } + + func testCredentialServerCandidatesUseResolvedHostNotBonjourServiceName() { + let profile = makeProfile( + host: "root@10.0.0.2", + bonjourName: "AirPort Time Capsule", + bonjourFullname: "AirPort Time Capsule._airport._tcp.local.", + hostname: "AirPort-Time-Capsule.local." + ) + + XCTAssertEqual(SMBAddressPolicy.credentialServerCandidates(for: profile), [ + "AirPort-Time-Capsule.local", + "10.0.0.2" + ]) + } + + func testReturnsNilWhenNoUsableHostExists() { + let profile = makeProfile(host: " ", bonjourName: nil, bonjourFullname: nil, hostname: ".") + + XCTAssertNil(SMBAddressPolicy.preferredHost(for: profile)) + XCTAssertNil(SMBAddressPolicy.url(for: profile)) + } + + private func makeProfile( + host: String, + bonjourName: String? = "Office Capsule", + bonjourFullname: String? = "Office Capsule._airport._tcp.local.", + hostname: String? + ) -> DeviceProfile { + DeviceProfile( + id: "device-one", + displayName: "Office Capsule", + host: host, + bonjourName: bonjourName, + bonjourFullname: bonjourFullname, + hostname: hostname, + addresses: [], + syap: "119", + model: "TimeCapsule6,116", + osName: nil, + osRelease: nil, + arch: nil, + elfEndianness: nil, + payloadFamily: nil, + deviceGeneration: nil, + configPath: "/tmp/device-one/.env", + keychainAccount: "device-one", + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + lastCheckup: nil, + lastDeploy: nil, + settings: .default, + passwordState: .available + ) + } +} diff --git a/macos/TimeCapsuleSMB/tools/package_app.py b/macos/TimeCapsuleSMB/tools/package_app.py index 7d7bf741..bbdcd31d 100755 --- a/macos/TimeCapsuleSMB/tools/package_app.py +++ b/macos/TimeCapsuleSMB/tools/package_app.py @@ -16,6 +16,7 @@ REPO_ROOT = PACKAGE_ROOT.parents[1] APP_NAME = "TimeCapsuleSMB" PRODUCT_NAME = "TimeCapsuleSMB" +ARTIFACT_MANIFEST = REPO_ROOT / "src" / "timecapsulesmb" / "assets" / "artifact-manifest.json" def run(cmd: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None, input_text: str | None = None) -> subprocess.CompletedProcess[str]: @@ -118,6 +119,24 @@ def copy_distribution(resources_dir: Path) -> None: shutil.rmtree(distribution) distribution.mkdir(parents=True) shutil.copytree(REPO_ROOT / "bin", distribution / "bin") + assert_distribution_artifacts(distribution) + + +def artifact_paths() -> list[str]: + data = json.loads(ARTIFACT_MANIFEST.read_text(encoding="utf-8")) + artifacts = data.get("artifacts", {}) + paths: list[str] = [] + for record in artifacts.values(): + if isinstance(record, dict) and isinstance(record.get("path"), str): + paths.append(record["path"]) + return sorted(paths) + + +def assert_distribution_artifacts(distribution: Path) -> None: + missing = [path for path in artifact_paths() if not (distribution / path).is_file()] + if missing: + joined = "\n - ".join(missing) + raise RuntimeError(f"Bundled distribution is missing payload artifact(s):\n - {joined}") def copy_tool(name: str, tools_bin: Path) -> bool: @@ -141,18 +160,58 @@ def copy_tools(resources_dir: Path, require_tools: bool) -> None: print(f"warning: missing optional bundled tool(s): {', '.join(missing)}", file=sys.stderr) +def parse_helper_events(stdout: str) -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + for line in stdout.splitlines(): + stripped = line.strip() + if not stripped: + continue + try: + value = json.loads(stripped) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + events.append(value) + return events + + def smoke_request(helper: Path, operation: str, state_dir: Path) -> None: env = os.environ.copy() env["TCAPSULE_STATE_DIR"] = str(state_dir) env["TCAPSULE_CONFIG"] = str(state_dir / ".env") request = json.dumps({"operation": operation, "params": {}}) completed = run([str(helper), "api"], input_text=request, env=env) - if '"type":"result"' not in completed.stdout and '"type": "result"' not in completed.stdout: + result_event = next( + ( + event + for event in parse_helper_events(completed.stdout) + if event.get("operation") == operation and event.get("type") == "result" + ), + None, + ) + if result_event is None: raise RuntimeError(f"{operation} smoke test did not emit a result event:\n{completed.stdout}\n{completed.stderr}") - if '"ok":false' in completed.stdout or '"ok": false' in completed.stdout: + if result_event.get("ok") is not True: raise RuntimeError(f"{operation} smoke test failed:\n{completed.stdout}\n{completed.stderr}") +def assert_bundle_layout(app: Path) -> None: + helper = app / "Contents" / "Helpers" / "tcapsule" + python = app / "Contents" / "Resources" / "Python" / "bin" / "python" + distribution = app / "Contents" / "Resources" / "Distribution" + tools_bin = app / "Contents" / "Resources" / "Tools" / "bin" + required_executables = [helper, python] + missing_executables = [path for path in required_executables if not path.is_file() or not os.access(path, os.X_OK)] + if missing_executables: + joined = "\n - ".join(str(path) for path in missing_executables) + raise RuntimeError(f"App bundle is missing required executable(s):\n - {joined}") + if not (distribution / "bin").is_dir(): + raise RuntimeError(f"App bundle is missing bundled payload directory: {distribution / 'bin'}") + if not tools_bin.is_dir(): + raise RuntimeError(f"App bundle is missing bundled tools directory: {tools_bin}") + assert_distribution_artifacts(distribution) + + def smoke_test(app: Path) -> None: helper = app / "Contents" / "Helpers" / "tcapsule" with tempfile.TemporaryDirectory(prefix="timecapsulesmb-package-smoke-") as tmp: @@ -183,6 +242,7 @@ def package_app(args: argparse.Namespace) -> Path: create_python_runtime(args.python, resources) copy_distribution(resources) copy_tools(resources, args.require_tools) + assert_bundle_layout(app) if not args.skip_smoke: smoke_test(app) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index 4b734f03..90d4ca5f 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -7,7 +7,13 @@ from timecapsulesmb.app.contracts import deploy_plan_payload, deploy_result_payload from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation from timecapsulesmb.app.events import EventSink -from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, airport_family_display_name_from_identity +from timecapsulesmb.core.config import ( + DEFAULTS, + MANAGED_PAYLOAD_DIR_NAME, + AppConfig, + airport_family_display_name_from_identity, + parse_bool, +) from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP from timecapsulesmb.core.net import extract_host from timecapsulesmb.core.paths import resolve_app_paths @@ -130,6 +136,16 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes 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)) + internal_share_use_disk_root = bool_param( + params, + "internal_share_use_disk_root", + parse_bool(config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), + ) + any_protocol = bool_param( + params, + "any_protocol", + parse_bool(config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ) sink.stage(operation, "validate_artifacts") failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] @@ -257,6 +273,8 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes payload_home, nbns_enabled=nbns_enabled, debug_logging=debug_logging, + internal_share_use_disk_root=internal_share_use_disk_root, + any_protocol=any_protocol, ) with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: tmpdir = Path(tmp) diff --git a/src/timecapsulesmb/services/deploy.py b/src/timecapsulesmb/services/deploy.py index 129b3ec6..33f1ecac 100644 --- a/src/timecapsulesmb/services/deploy.py +++ b/src/timecapsulesmb/services/deploy.py @@ -40,20 +40,32 @@ def render_flash_runtime_config( *, nbns_enabled: bool, debug_logging: bool, + internal_share_use_disk_root: bool | None = None, + any_protocol: bool | None = None, ata_idle_seconds: int = DEFAULT_ATA_IDLE_SECONDS, diskd_use_volume_attempts: int = DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, ) -> str: internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) configured_debug_logging = config.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"]) + effective_internal_root = ( + parse_bool(internal_root_default) + if internal_share_use_disk_root is None + else internal_share_use_disk_root + ) + effective_any_protocol = ( + parse_bool(any_protocol_default) + if any_protocol is None + else any_protocol + ) effective_debug_logging = debug_logging or parse_bool(configured_debug_logging) values: list[tuple[str, str | int]] = [ ("TC_CONFIG_VERSION", 2), ("TC_DEPLOY_RELEASE_TAG", RELEASE_TAG), ("TC_DEPLOY_CLI_VERSION_CODE", CLI_VERSION_CODE), - ("INTERNAL_SHARE_USE_DISK_ROOT", 1 if parse_bool(internal_root_default) else 0), - ("ANY_PROTOCOL", 1 if parse_bool(any_protocol_default) else 0), + ("INTERNAL_SHARE_USE_DISK_ROOT", 1 if effective_internal_root else 0), + ("ANY_PROTOCOL", 1 if effective_any_protocol else 0), ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), ("ATA_IDLE_SECONDS", ata_idle_seconds), ("NBNS_ENABLED", 1 if nbns_enabled else 0), diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 275f86fe..affb3dcc 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -940,21 +940,29 @@ def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: - rc = service.run_api_request( - { - "operation": "deploy", - "params": { - "dry_run": False, - "no_reboot": True, - "confirm_deploy": True, + with mock.patch("timecapsulesmb.app.ops.deploy.render_flash_runtime_config", return_value="runtime\n") as render_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "no_reboot": True, + "confirm_deploy": True, + "internal_share_use_disk_root": True, + "any_protocol": True, + "debug_logging": True, + }, }, - }, - collector.sink, - ) + collector.sink, + ) self.assertEqual(rc, 0) upload.assert_called_once() wait.assert_not_called() + render_runtime.assert_called_once() + self.assertEqual(render_runtime.call_args.kwargs["internal_share_use_disk_root"], True) + self.assertEqual(render_runtime.call_args.kwargs["any_protocol"], True) + self.assertEqual(render_runtime.call_args.kwargs["debug_logging"], True) 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: diff --git a/tests/test_macos_package_app.py b/tests/test_macos_package_app.py new file mode 100644 index 00000000..2576d240 --- /dev/null +++ b/tests/test_macos_package_app.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path + +import pytest + + +PACKAGE_SCRIPT = Path(__file__).resolve().parents[1] / "macos" / "TimeCapsuleSMB" / "tools" / "package_app.py" + + +def load_package_app_module(): + spec = importlib.util.spec_from_file_location("package_app", PACKAGE_SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_smoke_request_accepts_successful_result_event(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + calls: list[dict[str, object]] = [] + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append({"cmd": cmd, "kwargs": kwargs}) + return subprocess.CompletedProcess( + cmd, + 0, + stdout='{"type":"stage","operation":"capabilities"}\n{"type":"result","operation":"capabilities","ok":true}\n', + stderr="", + ) + + monkeypatch.setattr(package_app, "run", fake_run) + + package_app.smoke_request(tmp_path / "tcapsule", "capabilities", tmp_path) + + assert calls + assert calls[0]["cmd"] == [str(tmp_path / "tcapsule"), "api"] + + +def test_smoke_request_rejects_missing_result_event(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(cmd, 0, stdout='{"type":"stage","operation":"capabilities"}\n', stderr="") + + monkeypatch.setattr(package_app, "run", fake_run) + + with pytest.raises(RuntimeError, match="did not emit a result event"): + package_app.smoke_request(tmp_path / "tcapsule", "capabilities", tmp_path) + + +def test_smoke_request_rejects_failed_result_event(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + cmd, + 0, + stdout='{"type":"result","operation":"validate-install","ok":false}\n', + stderr="", + ) + + monkeypatch.setattr(package_app, "run", fake_run) + + with pytest.raises(RuntimeError, match="smoke test failed"): + package_app.smoke_request(tmp_path / "tcapsule", "validate-install", tmp_path) + + +def test_assert_bundle_layout_checks_helper_python_tools_and_artifacts( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + python = app / "Contents" / "Resources" / "Python" / "bin" / "python" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, python.parent, tools, distribution / "bin" / "payloads"): + directory.mkdir(parents=True) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + python.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + python.chmod(0o755) + + monkeypatch.setattr(package_app, "artifact_paths", lambda: ["bin/payloads/one", "bin/payloads/two"]) + (distribution / "bin" / "payloads" / "one").write_text("one", encoding="utf-8") + + with pytest.raises(RuntimeError, match="missing payload artifact"): + package_app.assert_bundle_layout(app) + + (distribution / "bin" / "payloads" / "two").write_text("two", encoding="utf-8") + + package_app.assert_bundle_layout(app) diff --git a/tests/test_storage_runtime.py b/tests/test_storage_runtime.py index 60f4ba20..b1eecfee 100644 --- a/tests/test_storage_runtime.py +++ b/tests/test_storage_runtime.py @@ -909,6 +909,46 @@ def test_flash_runtime_config_uses_saved_debug_logging(self) -> None: self.assertIn("SMBD_DEBUG_LOGGING=1\n", rendered) self.assertIn("MDNS_DEBUG_LOGGING=1\n", rendered) + def test_flash_runtime_config_accepts_deploy_time_advanced_overrides(self) -> None: + config = AppConfig.from_values( + { + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "false", + "TC_ANY_PROTOCOL": "false", + } + ) + + rendered = render_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=True, + debug_logging=False, + internal_share_use_disk_root=True, + any_protocol=True, + ) + + self.assertIn("INTERNAL_SHARE_USE_DISK_ROOT=1\n", rendered) + self.assertIn("ANY_PROTOCOL=1\n", rendered) + + def test_flash_runtime_config_deploy_time_overrides_can_disable_saved_values(self) -> None: + config = AppConfig.from_values( + { + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true", + "TC_ANY_PROTOCOL": "true", + } + ) + + rendered = render_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=True, + debug_logging=False, + internal_share_use_disk_root=False, + any_protocol=False, + ) + + self.assertIn("INTERNAL_SHARE_USE_DISK_ROOT=0\n", rendered) + self.assertIn("ANY_PROTOCOL=0\n", rendered) + def test_common_runtime_identity_normalizers_match_python(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) From bee307d546581061f7e787cce1380cdce4a62afa Mon Sep 17 00:00:00 2001 From: James Chang Date: Thu, 21 May 2026 18:07:44 -0700 Subject: [PATCH 026/129] Add parallel GUI operation lanes --- .../TimeCapsuleSMBApp/App/AppStore.swift | 9 +- .../Backend/BackendClient.swift | 4 + .../Profiles/DeviceProfileEditorStore.swift | 7 +- .../Resources/en.lproj/Localizable.strings | 5 + .../Views/Dashboard/AdvancedTab.swift | 2 +- .../Views/Shell/ActivityView.swift | 192 ++++++++-- .../Views/Shell/ContentView.swift | 36 +- .../ActivityProgressTextAnimator.swift | 4 + .../Workflows/ActivityStore.swift | 299 ++++++++++++--- .../Workflows/AddDeviceFlowStore.swift | 104 ++++- .../Workflows/DashboardStore.swift | 6 +- .../Workflows/DeployWorkflowStore.swift | 34 +- .../Workflows/DeviceDashboardSession.swift | 23 +- .../DeviceDiscoveryMonitorStore.swift | 18 +- .../Workflows/DoctorStore.swift | 30 +- .../Workflows/MaintenanceStore.swift | 48 ++- .../Workflows/OperationCoordinator.swift | 359 ++++++++++++++++-- .../ActivityProgressTextAnimatorTests.swift | 24 ++ .../ActivityStoreTests.swift | 353 +++++++++++++++++ .../AddDeviceFlowStoreTests.swift | 43 ++- .../BackendClientTests.swift | 18 +- .../DashboardStoreTests.swift | 38 +- .../DeviceDiscoveryMonitorStoreTests.swift | 31 ++ .../OperationCoordinatorLaneTests.swift | 311 +++++++++++++++ .../StoreTestSupport.swift | 67 ++++ src/timecapsulesmb/core/config.py | 25 +- tests/test_config.py | 14 + 27 files changed, 1891 insertions(+), 213 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift index 3726ef71..db61aa81 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift @@ -19,7 +19,7 @@ final class AppStore: ObservableObject { convenience init() { let coordinator = OperationCoordinator() self.init( - appReadinessStore: AppReadinessStore(backend: coordinator.backend), + appReadinessStore: AppReadinessStore(backend: coordinator.appLane.backend), deviceRegistry: DeviceRegistryStore(), operationCoordinator: coordinator, passwordStore: KeychainPasswordStore(), @@ -85,7 +85,7 @@ final class AppStore: ObservableObject { } var backend: BackendClient { - operationCoordinator.backend + operationCoordinator.appLane.backend } func start() async { @@ -115,15 +115,16 @@ final class AppStore: ObservableObject { func dashboardSummary(for profile: DeviceProfile) -> DeviceDashboardSummary { let passwordState = effectivePasswordState(for: profile) + let activeOperation = operationCoordinator.activeOperation(for: profile) let displayStatus = DeviceStatusPolicy.status( for: profile, passwordState: passwordState, - activeOperation: operationCoordinator.activeOperation + activeOperation: activeOperation ) let primaryAction = DashboardPrimaryActionPolicy.primaryAction( for: profile, passwordState: passwordState, - activeOperation: operationCoordinator.activeOperation + activeOperation: activeOperation ) return DeviceDashboardSummary( profile: profile, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift index 7f6618a5..19b2a1bc 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift @@ -28,6 +28,10 @@ final class BackendClient: ObservableObject { runTask?.cancel() } + func makeSibling() -> BackendClient { + BackendClient(runner: runner, helperPath: helperPath) + } + func clear() { guard !isRunning else { return diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift index 13e613b0..c3ba7faf 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift @@ -124,6 +124,7 @@ final class DeviceProfileEditorStore: ObservableObject { private let appStore: AppStore private let coordinator: OperationCoordinator + private let lane: OperationLane private let profileSaver: ConfiguredDeviceProfileSaving private var activeOperation: ActiveOperation? private var pendingProfile: DeviceProfile? @@ -141,6 +142,7 @@ final class DeviceProfileEditorStore: ObservableObject { self.draft = DeviceProfileEditorDraft(profile: profile) self.appStore = appStore self.coordinator = appStore.operationCoordinator + self.lane = appStore.operationCoordinator.lane(for: profile) self.profileSaver = profileSaver ?? ConfiguredDeviceProfileSaver( registry: appStore.deviceRegistry, passwordStore: appStore.passwordStore @@ -189,7 +191,7 @@ final class DeviceProfileEditorStore: ObservableObject { } private func observeBackend() { - coordinator.backend.$events + lane.backend.$events .sink { [weak self] events in Task { @MainActor in self?.process(events) @@ -237,7 +239,8 @@ final class DeviceProfileEditorStore: ObservableObject { operation: "configure", params: params, context: profile.runtimeContext, - activeDeviceID: profile.id + activeDeviceID: profile.id, + laneKey: .device(profile.id) ) guard case .started(let operation) = start else { error = BackendErrorViewModel( diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 78311ad6..733f6f88 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -254,8 +254,13 @@ "host_warning.time_machine.message" = "macOS %d.%d.%d has known Time Machine network backup issues. SMB may work, but backup reliability can be affected by the host OS."; "host_warning.time_machine.title" = "macOS Time Machine Warning"; "activity.app_ready" = "App Ready"; +"activity.active" = "Active"; "activity.last_operation" = "Last operation"; +"activity.multiple_active" = "%d active operations"; +"activity.multiple_active.message" = "Open Activity for details."; "activity.no_active_operation" = "No active operation"; +"activity.one_active" = "1 active operation"; +"activity.recent" = "Recent"; "activity.timeline" = "Timeline"; "activity.timeline.empty" = "No operation history yet."; "discovery_monitor.last_seen.now" = "Seen now"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift index b6a157cc..2136869b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift @@ -15,7 +15,7 @@ struct AdvancedTab: View { (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) + EventList(events: session.events) } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift index 99481ef5..f9c9c278 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ActivityCompactView: View { @ObservedObject var activityStore: ActivityStore @ObservedObject var registry: DeviceRegistryStore + let context: ActivityDisplayContext @State private var messageAnimationPhase = 0 private let messageAnimationTimer = Timer.publish( @@ -12,15 +13,15 @@ struct ActivityCompactView: View { ).autoconnect() var body: some View { - let snapshot = activityStore.snapshot - let hasLatestMessage = hasLatestMessage(snapshot) + let status = activityStore.compactStatus(for: context) + let hasLatestMessage = hasLatestMessage(status) HStack(spacing: 10) { - Image(systemName: snapshot.isRunning ? "hourglass" : "checkmark.circle") - .foregroundStyle(snapshot.isRunning ? Color.accentColor : Color.secondary) - messageView(snapshot, hasLatestMessage: hasLatestMessage) + Image(systemName: icon(for: status)) + .foregroundStyle(iconColor(for: status)) + messageView(status, hasLatestMessage: hasLatestMessage) Spacer() - if let last = snapshot.timeline.last { - Text(last.title) + if let latestTimelineTitle = status.latestTimelineTitle { + Text(latestTimelineTitle) .font(.caption2) .foregroundStyle(.secondary) } @@ -28,22 +29,22 @@ struct ActivityCompactView: View { .padding(.horizontal) .padding(.vertical, 8) .background(Color.secondary.opacity(0.06)) - .onChange(of: ActivityProgressTextAnimator.animationIdentity(for: snapshot)) { _ in + .onChange(of: ActivityProgressTextAnimator.animationIdentity(for: status)) { _ in messageAnimationPhase = 0 } .onReceive(messageAnimationTimer) { _ in - advanceMessageAnimation(for: snapshot) + advanceMessageAnimation(for: status) } } @ViewBuilder - private func messageView(_ snapshot: ActivitySnapshot, hasLatestMessage: Bool) -> some View { + private func messageView(_ status: ActivityCompactStatus, hasLatestMessage: Bool) -> some View { if hasLatestMessage { VStack(alignment: .leading, spacing: 2) { - Text(title(snapshot)) + Text(title(status)) .font(.caption.weight(.medium)) .lineLimit(1) - Text(latestMessage(snapshot)) + Text(latestMessage(status)) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) @@ -51,23 +52,23 @@ struct ActivityCompactView: View { } .frame(height: 30, alignment: .center) } else { - Text(title(snapshot)) + Text(title(status)) .font(.caption.weight(.medium)) .lineLimit(1) .frame(height: 30, alignment: .center) } } - private func latestMessage(_ snapshot: ActivitySnapshot) -> String { + private func latestMessage(_ status: ActivityCompactStatus) -> String { ActivityProgressTextAnimator.message( - snapshot.latestMessage, - isRunning: snapshot.isRunning, + status.latestMessage, + isRunning: status.isRunning, phase: messageAnimationPhase ) ?? "" } - private func advanceMessageAnimation(for snapshot: ActivitySnapshot) { - guard ActivityProgressTextAnimator.animationIdentity(for: snapshot) != nil else { + private func advanceMessageAnimation(for status: ActivityCompactStatus) { + guard ActivityProgressTextAnimator.animationIdentity(for: status) != nil else { if messageAnimationPhase != 0 { messageAnimationPhase = 0 } @@ -76,20 +77,34 @@ struct ActivityCompactView: View { messageAnimationPhase = ActivityProgressTextAnimator.nextPhase(after: messageAnimationPhase) } - private func title(_ snapshot: ActivitySnapshot) -> String { - if case .device(let activeDeviceID) = snapshot.scope, + private func title(_ status: ActivityCompactStatus) -> String { + if case .device(let activeDeviceID) = status.scope, let profile = registry.profile(id: activeDeviceID) { - return "\(snapshot.operationTitle) - \(profile.title)" + return "\(status.operationTitle) - \(profile.title)" } - return snapshot.operationTitle + return status.operationTitle } - private func hasLatestMessage(_ snapshot: ActivitySnapshot) -> Bool { - guard let latestMessage = snapshot.latestMessage else { + private func hasLatestMessage(_ status: ActivityCompactStatus) -> Bool { + guard let latestMessage = status.latestMessage else { return false } return !latestMessage.isEmpty } + + private func icon(for status: ActivityCompactStatus) -> String { + if status.requiresAttention { + return "exclamationmark.triangle" + } + return status.isRunning ? "hourglass" : "checkmark.circle" + } + + private func iconColor(for status: ActivityCompactStatus) -> Color { + if status.requiresAttention { + return .yellow + } + return status.isRunning ? Color.accentColor : Color.secondary + } } struct ActivityDetailView: View { @@ -97,34 +112,38 @@ struct ActivityDetailView: View { @ObservedObject var registry: DeviceRegistryStore var body: some View { - let snapshot = activityStore.snapshot ScrollView { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 12) { - Image(systemName: snapshot.isRunning ? "hourglass" : "clock") + Image(systemName: activityStore.hasActiveActivity ? "hourglass" : "clock") .font(.title2) - .foregroundStyle(snapshot.isRunning ? Color.accentColor : Color.secondary) + .foregroundStyle(activityStore.hasActiveActivity ? Color.accentColor : Color.secondary) VStack(alignment: .leading, spacing: 4) { - Text(title(snapshot)) + Text(L10n.string("sidebar.activity")) .font(.title2.weight(.semibold)) - if let latestMessage = snapshot.latestMessage, !latestMessage.isEmpty { - Text(latestMessage) - .foregroundStyle(.secondary) - } + Text(activeActivityMessage) + .foregroundStyle(.secondary) } Spacer() } - VStack(alignment: .leading, spacing: 8) { - Text(L10n.string("activity.timeline")) - .font(.headline) - if snapshot.timeline.isEmpty { - Text(L10n.string("activity.timeline.empty")) - .foregroundStyle(.secondary) - } else { - ForEach(snapshot.timeline) { item in - ActivityTimelineRow(item: item) - } + if activityStore.activeLaneSnapshots.isEmpty && activityStore.recentLaneSnapshots.isEmpty { + Text(L10n.string("activity.timeline.empty")) + .foregroundStyle(.secondary) + } else { + if !activityStore.activeLaneSnapshots.isEmpty { + ActivityLaneSection( + title: L10n.string("activity.active"), + snapshots: activityStore.activeLaneSnapshots, + registry: registry + ) + } + if !activityStore.recentLaneSnapshots.isEmpty { + ActivityLaneSection( + title: L10n.string("activity.recent"), + snapshots: activityStore.recentLaneSnapshots, + registry: registry + ) } } } @@ -140,6 +159,95 @@ struct ActivityDetailView: View { } return snapshot.operationTitle } + + private var activeActivityMessage: String { + guard activityStore.hasActiveActivity else { + return L10n.string("activity.no_active_operation") + } + let count = activityStore.activeLaneSnapshots.count + return count == 1 + ? L10n.string("activity.one_active") + : L10n.format("activity.multiple_active", count) + } +} + +private struct ActivityLaneSection: View { + let title: String + let snapshots: [ActivityLaneSnapshot] + @ObservedObject var registry: DeviceRegistryStore + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + ForEach(snapshots) { laneSnapshot in + ActivityLaneCard(laneSnapshot: laneSnapshot, registry: registry) + } + } + } +} + +private struct ActivityLaneCard: View { + let laneSnapshot: ActivityLaneSnapshot + @ObservedObject var registry: DeviceRegistryStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon) + .foregroundStyle(iconColor) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(title(laneSnapshot.snapshot)) + .font(.body.weight(.medium)) + if let latestMessage = laneSnapshot.snapshot.latestMessage, !latestMessage.isEmpty { + Text(latestMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + + VStack(alignment: .leading, spacing: 4) { + if laneSnapshot.snapshot.timeline.isEmpty { + Text(L10n.string("activity.timeline.empty")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(laneSnapshot.snapshot.timeline) { item in + ActivityTimelineRow(item: item) + } + } + } + .padding(.leading, 26) + } + .padding(10) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var icon: String { + if laneSnapshot.isPendingConfirmation { + return "exclamationmark.triangle" + } + return laneSnapshot.snapshot.isRunning ? "hourglass" : "clock" + } + + private var iconColor: Color { + if laneSnapshot.isPendingConfirmation { + return .yellow + } + return laneSnapshot.snapshot.isRunning ? Color.accentColor : Color.secondary + } + + private func title(_ snapshot: ActivitySnapshot) -> String { + if case .device(let activeDeviceID) = snapshot.scope, + let profile = registry.profile(id: activeDeviceID) { + return "\(snapshot.operationTitle) - \(profile.title)" + } + return snapshot.operationTitle + } } private struct ActivityTimelineRow: View { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift index 133d97db..2f35808c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift @@ -37,7 +37,8 @@ public struct ContentView: View { Divider() ActivityCompactView( activityStore: appStore.activityStore, - registry: appStore.deviceRegistry + registry: appStore.deviceRegistry, + context: activityDisplayContext ) } } @@ -58,7 +59,7 @@ public struct ContentView: View { ToolbarIconButton( title: L10n.string("toolbar.forget"), systemImage: "trash", - disabled: appStore.selectedProfile == nil || appStore.backend.isRunning + disabled: selectedProfileIsBusy ) { guard let profile = appStore.selectedProfile else { return @@ -68,7 +69,7 @@ public struct ContentView: View { ToolbarIconButton( title: L10n.string("toolbar.cancel"), systemImage: "xmark.circle", - disabled: !appStore.backend.canCancel + disabled: !appStore.operationCoordinator.canCancel ) { appStore.operationCoordinator.cancel() } @@ -122,15 +123,15 @@ public struct ContentView: View { Text(deleteErrorMessage ?? "") } .alert( - appStore.backend.pendingConfirmation?.title ?? "", + appStore.operationCoordinator.pendingConfirmation?.title ?? "", isPresented: confirmationPresented, - presenting: appStore.backend.pendingConfirmation + presenting: appStore.operationCoordinator.pendingConfirmation ) { confirmation in Button(confirmation.actionTitle, role: .destructive) { - appStore.backend.confirmPending() + appStore.operationCoordinator.confirmPending() } Button(L10n.string("action.cancel"), role: .cancel) { - appStore.backend.pendingConfirmation = nil + appStore.operationCoordinator.cancelPendingConfirmation() } } message: { confirmation in Text(confirmation.message) @@ -161,15 +162,22 @@ public struct ContentView: View { private var confirmationPresented: Binding { Binding( - get: { appStore.backend.pendingConfirmation != nil }, + get: { appStore.operationCoordinator.pendingConfirmation != nil }, set: { isPresented in if !isPresented { - appStore.backend.pendingConfirmation = nil + appStore.operationCoordinator.cancelPendingConfirmation() } } ) } + private var selectedProfileIsBusy: Bool { + guard let profile = appStore.selectedProfile else { + return true + } + return appStore.operationCoordinator.lane(for: profile).isBusy + } + private var sidebarSelection: Binding { Binding( get: { @@ -208,7 +216,7 @@ public struct ContentView: View { List(selection: sidebarSelection) { Label(L10n.string("sidebar.all_time_capsules"), systemImage: "externaldrive.connected.to.line.below") .tag("all") - Label(L10n.string("sidebar.activity"), systemImage: appStore.activityStore.snapshot.isRunning ? "hourglass" : "clock") + Label(L10n.string("sidebar.activity"), systemImage: appStore.activityStore.hasActiveActivity ? "hourglass" : "clock") .tag("activity") Section(L10n.string("sidebar.devices")) { @@ -231,6 +239,14 @@ public struct ContentView: View { .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 360) } + private var activityDisplayContext: ActivityDisplayContext { + ActivityDisplayContext( + selectedDeviceID: appStore.selectedDeviceID, + showingAddDevice: appStore.showingAddDevice, + showingActivity: appStore.showingActivity + ) + } + @ViewBuilder private var detail: some View { if appStore.showingActivity { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift index a7f340b2..19deb90f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift @@ -25,6 +25,10 @@ enum ActivityProgressTextAnimator { shouldAnimate(snapshot.latestMessage, isRunning: snapshot.isRunning) ? snapshot.latestMessage : nil } + static func animationIdentity(for status: ActivityCompactStatus) -> String? { + shouldAnimate(status.latestMessage, isRunning: status.isRunning) ? status.latestMessage : nil + } + static func nextPhase(after phase: Int) -> Int { (frameIndex(phase) + 1) % frameCount } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift index 89a2471b..177d37d7 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift @@ -15,6 +15,51 @@ struct ActivitySnapshot: Equatable { let timeline: [OperationTimelineItem] } +struct ActivityLaneSnapshot: Equatable, Identifiable { + let laneKey: OperationLaneKey + let snapshot: ActivitySnapshot + let isPendingConfirmation: Bool + let updateSequence: Int + + var id: OperationLaneKey { + laneKey + } +} + +struct ActivityDisplayContext: Equatable { + let selectedDeviceID: DeviceProfile.ID? + let showingAddDevice: Bool + let showingActivity: Bool + + static let none = ActivityDisplayContext( + selectedDeviceID: nil, + showingAddDevice: false, + showingActivity: false + ) +} + +struct ActivityCompactStatus: Equatable { + let isRunning: Bool + let requiresAttention: Bool + let scope: ActivityScope + let operationTitle: String + let latestMessage: String? + let latestTimelineTitle: String? + let activeLaneCount: Int + + static func from(_ laneSnapshot: ActivityLaneSnapshot, activeLaneCount: Int) -> ActivityCompactStatus { + ActivityCompactStatus( + isRunning: laneSnapshot.snapshot.isRunning, + requiresAttention: laneSnapshot.isPendingConfirmation, + scope: laneSnapshot.snapshot.scope, + operationTitle: laneSnapshot.snapshot.operationTitle, + latestMessage: laneSnapshot.snapshot.latestMessage, + latestTimelineTitle: laneSnapshot.snapshot.timeline.last?.title, + activeLaneCount: activeLaneCount + ) + } +} + @MainActor final class ActivityStore: ObservableObject { @Published private(set) var snapshot = ActivitySnapshot( @@ -24,41 +69,18 @@ final class ActivityStore: ObservableObject { latestMessage: nil, timeline: [] ) + @Published private(set) var laneSnapshots: [ActivityLaneSnapshot] = [] private let coordinator: OperationCoordinator private var cancellables: Set = [] + private var previousSnapshots: [OperationLaneKey: ActivitySnapshot] = [:] + private var previousPendingStates: [OperationLaneKey: Bool] = [:] + private var laneUpdateSequences: [OperationLaneKey: Int] = [:] + private var nextUpdateSequence = 1 init(coordinator: OperationCoordinator) { self.coordinator = coordinator - coordinator.$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 + coordinator.$lanesRevision .sink { [weak self] _ in Task { @MainActor in self?.refresh() @@ -69,29 +91,124 @@ final class ActivityStore: ObservableObject { } func refresh() { - let events = coordinator.backend.events + laneSnapshots = coordinator.allLanes + .map { lane in + let snapshot = snapshot(for: lane) + let isPendingConfirmation = lane.backend.pendingConfirmation != nil + let updateSequence = updateSequence( + for: lane.key, + snapshot: snapshot, + isPendingConfirmation: isPendingConfirmation + ) + return ActivityLaneSnapshot( + laneKey: lane.key, + snapshot: snapshot, + isPendingConfirmation: isPendingConfirmation, + updateSequence: updateSequence + ) + } + .filter { laneSnapshot in + laneSnapshot.snapshot.isRunning + || laneSnapshot.isPendingConfirmation + || !laneSnapshot.snapshot.timeline.isEmpty + } + .sorted(by: sortLaneSnapshots) + snapshot = primarySnapshot(from: laneSnapshots) ?? emptySnapshot() + } + + var activeLaneSnapshots: [ActivityLaneSnapshot] { + laneSnapshots.filter { $0.snapshot.isRunning || $0.isPendingConfirmation } + } + + var recentLaneSnapshots: [ActivityLaneSnapshot] { + laneSnapshots.filter { !$0.snapshot.isRunning && !$0.isPendingConfirmation } + } + + var hasActiveActivity: Bool { + !activeLaneSnapshots.isEmpty + } + + func compactStatus(for context: ActivityDisplayContext) -> ActivityCompactStatus { + let active = activeLaneSnapshots + let activeCount = active.count + + if let selectedDeviceID = context.selectedDeviceID, + let selected = laneSnapshots.first(where: { isDeviceLane($0, selectedDeviceID: selectedDeviceID) }), + selected.snapshot.isRunning || selected.isPendingConfirmation { + return .from(selected, activeLaneCount: activeCount) + } + + if context.showingAddDevice, + let configureLane = active.first(where: { laneSnapshot in + laneSnapshot.laneKey != .app + && laneSnapshot.snapshot.operationTitle == OperationTimelineBuilder.operationTitle("configure") + }) { + return .from(configureLane, activeLaneCount: activeCount) + } + + if context.showingAddDevice || (context.selectedDeviceID == nil && !context.showingActivity), + let appLane = active.first(where: { $0.laneKey == .app }) { + return .from(appLane, activeLaneCount: activeCount) + } + + if activeCount > 1 { + return ActivityCompactStatus( + isRunning: active.contains { $0.snapshot.isRunning }, + requiresAttention: active.contains { $0.isPendingConfirmation }, + scope: .unknown, + operationTitle: L10n.format("activity.multiple_active", activeCount), + latestMessage: L10n.string("activity.multiple_active.message"), + latestTimelineTitle: nil, + activeLaneCount: activeCount + ) + } + + if let activeLane = active.first { + return .from(activeLane, activeLaneCount: activeCount) + } + + if let selectedDeviceID = context.selectedDeviceID, + let selected = laneSnapshots.first(where: { isDeviceLane($0, selectedDeviceID: selectedDeviceID) }) { + return .from(selected, activeLaneCount: activeCount) + } + + if context.showingAddDevice || (context.selectedDeviceID == nil && !context.showingActivity), + let appLane = laneSnapshots.first(where: { $0.laneKey == .app }) { + return .from(appLane, activeLaneCount: activeCount) + } + + if let recent = laneSnapshots.first { + return .from(recent, activeLaneCount: activeCount) + } + + let empty = emptySnapshot() + return ActivityCompactStatus( + isRunning: empty.isRunning, + requiresAttention: false, + scope: empty.scope, + operationTitle: empty.operationTitle, + latestMessage: empty.latestMessage, + latestTimelineTitle: empty.timeline.last?.title, + activeLaneCount: activeCount + ) + } + + private func snapshot(for lane: OperationLane) -> ActivitySnapshot { + let events = lane.backend.events let timeline = OperationTimelineBuilder.timeline(from: events) - let operation = coordinator.activeOperation?.operation - ?? coordinator.backend.activeOperationName + let operation = lane.activeOperation?.operation + ?? lane.backend.activeOperationName ?? latestOperation(from: events) - let isRunning = coordinator.backend.isRunning + let isRunning = lane.backend.isRunning let presentation = presentation( operation: operation, events: events, timeline: timeline, isRunning: isRunning ) - let scope: ActivityScope - if let activeDeviceID = coordinator.activeDeviceID { - scope = .device(activeDeviceID) - } else if isAppOperation(operation) { - scope = .app - } else { - scope = .unknown - } - snapshot = ActivitySnapshot( + return ActivitySnapshot( isRunning: isRunning, - scope: scope, + scope: scope(for: lane.key, operation: operation), operationTitle: presentation.title, latestMessage: presentation.message, timeline: timeline @@ -132,10 +249,106 @@ final class ActivityStore: ObservableObject { events.last?.operation } + private func primarySnapshot(from snapshots: [ActivityLaneSnapshot]) -> ActivitySnapshot? { + if let runningDevice = snapshots.first(where: { laneSnapshot in + laneSnapshot.snapshot.isRunning && isDeviceScope(laneSnapshot.snapshot.scope) + }) { + return runningDevice.snapshot + } + if let runningApp = snapshots.first(where: { laneSnapshot in + laneSnapshot.snapshot.isRunning && laneSnapshot.snapshot.scope == .app + }) { + return runningApp.snapshot + } + if let pending = snapshots.first(where: \.isPendingConfirmation) { + return pending.snapshot + } + return snapshots.first?.snapshot + } + + private func scope(for laneKey: OperationLaneKey, operation: String?) -> ActivityScope { + switch laneKey { + case .app: + return .app + case .device(let profileID): + return .device(profileID) + case .candidateHost, .localPath: + return isAppOperation(operation) ? .app : .unknown + } + } + + private func isDeviceScope(_ scope: ActivityScope) -> Bool { + if case .device = scope { + return true + } + return false + } + + private func emptySnapshot() -> ActivitySnapshot { + ActivitySnapshot( + isRunning: false, + scope: .unknown, + operationTitle: L10n.string("activity.no_active_operation"), + latestMessage: nil, + timeline: [] + ) + } + private func isAppOperation(_ operation: String?) -> Bool { guard let operation else { return false } return ["capabilities", "validate-install", "paths", "discover"].contains(operation) } + + private func updateSequence( + for laneKey: OperationLaneKey, + snapshot: ActivitySnapshot, + isPendingConfirmation: Bool + ) -> Int { + defer { + previousSnapshots[laneKey] = snapshot + previousPendingStates[laneKey] = isPendingConfirmation + } + + if previousSnapshots[laneKey] != snapshot || previousPendingStates[laneKey] != isPendingConfirmation { + let sequence = nextUpdateSequence + laneUpdateSequences[laneKey] = sequence + nextUpdateSequence += 1 + return sequence + } + return laneUpdateSequences[laneKey] ?? 0 + } + + private func sortLaneSnapshots(_ left: ActivityLaneSnapshot, _ right: ActivityLaneSnapshot) -> Bool { + let leftPriority = lanePriority(left) + let rightPriority = lanePriority(right) + if leftPriority != rightPriority { + return leftPriority < rightPriority + } + if left.updateSequence != right.updateSequence { + return left.updateSequence > right.updateSequence + } + return left.laneKey.description < right.laneKey.description + } + + private func lanePriority(_ laneSnapshot: ActivityLaneSnapshot) -> Int { + if laneSnapshot.isPendingConfirmation { + return 0 + } + if laneSnapshot.snapshot.isRunning { + return 1 + } + return 2 + } + + private func isDeviceLane(_ laneSnapshot: ActivityLaneSnapshot, selectedDeviceID: DeviceProfile.ID) -> Bool { + if case .device(let profileID) = laneSnapshot.snapshot.scope { + return profileID == selectedDeviceID + } + if case .device(let profileID) = laneSnapshot.laneKey { + return profileID == selectedDeviceID + } + return false + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift index 2e273ce0..d73c99ad 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift @@ -79,12 +79,15 @@ final class AddDeviceFlowStore: ObservableObject { let registry: DeviceRegistryStore let passwordStore: PasswordStore let profileSaver: ConfiguredDeviceProfileSaving + private let appLane: OperationLane private var pendingProfileID: DeviceProfile.ID? private var pendingDiscoveredDevice: DiscoveredDevice? private var activeOperation: ActiveOperation? - private var lastProcessedEventCount = 0 + private var activeLaneKey: OperationLaneKey? + private var lastProcessedEventCounts: [OperationLaneKey: Int] = [:] private var cancellables: Set = [] + private var observedLaneKeys: Set = [] init( coordinator: OperationCoordinator, @@ -96,21 +99,37 @@ final class AddDeviceFlowStore: ObservableObject { self.registry = registry self.passwordStore = passwordStore self.profileSaver = profileSaver ?? ConfiguredDeviceProfileSaver(registry: registry, passwordStore: passwordStore) - coordinator.backend.$events + self.appLane = coordinator.appLane + observe(lane: appLane) + } + + private func observe(lane: OperationLane) { + guard observedLaneKeys.insert(lane.key).inserted else { + return + } + lane.backend.$events .sink { [weak self] events in Task { @MainActor in - self?.process(events) + self?.process(events, laneKey: lane.key) } } .store(in: &cancellables) } var isRunning: Bool { - coordinator.backend.isRunning + switch activeLaneKey { + case .some(let key): + return coordinator.lane(for: key).backend.isRunning + case .none: + return false + } } var canCancel: Bool { - coordinator.backend.canCancel + guard let activeLaneKey else { + return false + } + return coordinator.lane(for: activeLaneKey).backend.canCancel } var selectedDevice: DiscoveredDevice? { @@ -192,16 +211,23 @@ final class AddDeviceFlowStore: ObservableObject { failLocally(L10n.string("add_device.error.invalid_bonjour_timeout")) return } - guard !coordinator.backend.isRunning else { + guard !appLane.isBusy else { rejectRun(L10n.string("operation.error.already_running")) return } resetRunState(clearDevices: true) entryMode = .discover manualHost = "" - switch coordinator.run(operation: "discover", params: OperationParams.discover(timeout: timeout), profile: nil) { + switch coordinator.run( + operation: "discover", + params: OperationParams.discover(timeout: timeout), + context: nil, + activeDeviceID: nil, + laneKey: .app + ) { case .started(let operation): activeOperation = operation + activeLaneKey = .app state = .discovering case .rejected(let message): rejectRun(message) @@ -233,13 +259,18 @@ final class AddDeviceFlowStore: ObservableObject { configURL: DeviceProfile.configURL(for: profileID, applicationSupportURL: registry.applicationSupportURL) ) - guard !coordinator.backend.isRunning else { + let laneKey = OperationLaneKey.device(profileID) + let lane = coordinator.lane(for: laneKey) + observe(lane: lane) + + guard !lane.isBusy else { pendingProfileID = nil pendingDiscoveredDevice = nil rejectRun(L10n.string("operation.error.already_running")) return } resetRunState(clearDevices: false) + lastProcessedEventCounts[laneKey] = 0 switch coordinator.run( operation: "configure", params: OperationParams.configure( @@ -249,10 +280,12 @@ final class AddDeviceFlowStore: ObservableObject { debugLogging: debugLogging ), context: context, - activeDeviceID: profileID + activeDeviceID: profileID, + laneKey: laneKey ) { case .started(let operation): activeOperation = operation + activeLaneKey = laneKey state = .configuring case .rejected(let message): pendingProfileID = nil @@ -279,8 +312,8 @@ final class AddDeviceFlowStore: ObservableObject { } func stageDiscoveredDevices(_ discoveredDevices: [DiscoveredDevice], selected device: DiscoveredDevice) { - if !coordinator.backend.isRunning { - coordinator.backend.clear() + if !appLane.isBusy { + appLane.clear() } entryMode = .discover var stagedDevices = discoveredDevices @@ -295,12 +328,21 @@ final class AddDeviceFlowStore: ObservableObject { pendingProfileID = nil pendingDiscoveredDevice = nil activeOperation = nil - lastProcessedEventCount = 0 + activeLaneKey = nil + lastProcessedEventCounts[.app] = 0 select(device) } func reset() { - coordinator.backend.clear() + if !appLane.isBusy { + appLane.clear() + } + if let activeLaneKey, activeLaneKey != .app { + let lane = coordinator.lane(for: activeLaneKey) + if !lane.isBusy { + lane.clear() + } + } devices = [] selectedDeviceID = nil entryMode = .discover @@ -312,21 +354,35 @@ final class AddDeviceFlowStore: ObservableObject { pendingProfileID = nil pendingDiscoveredDevice = nil activeOperation = nil - lastProcessedEventCount = 0 + activeLaneKey = nil + lastProcessedEventCounts = [:] state = .idle } func cancel() { - coordinator.cancel() + guard let activeLaneKey else { + return + } + coordinator.cancel(laneKey: activeLaneKey) } private func resetRunState(clearDevices: Bool) { - coordinator.backend.clear() - lastProcessedEventCount = 0 + let laneKey = activeLaneKey ?? (state == .discovering ? .app : nil) + if let laneKey { + let lane = coordinator.lane(for: laneKey) + if !lane.isBusy { + lane.clear() + } + lastProcessedEventCounts[laneKey] = 0 + } else if !appLane.isBusy { + appLane.clear() + lastProcessedEventCounts[.app] = 0 + } error = nil currentStage = nil savedProfile = nil activeOperation = nil + activeLaneKey = nil if clearDevices { devices = [] selectedDeviceID = nil @@ -336,7 +392,8 @@ final class AddDeviceFlowStore: ObservableObject { } } - private func process(_ events: [BackendEvent]) { + private func process(_ events: [BackendEvent], laneKey: OperationLaneKey) { + var lastProcessedEventCount = lastProcessedEventCounts[laneKey, default: 0] if events.count < lastProcessedEventCount { lastProcessedEventCount = 0 } @@ -346,7 +403,7 @@ final class AddDeviceFlowStore: ObservableObject { for event in events.dropFirst(lastProcessedEventCount) { handle(event) } - lastProcessedEventCount = events.count + lastProcessedEventCounts[laneKey] = events.count } private func handle(_ event: BackendEvent) { @@ -392,6 +449,7 @@ final class AddDeviceFlowStore: ObservableObject { state = devices.isEmpty ? .discoveryEmpty : .discoveryReady error = nil activeOperation = nil + activeLaneKey = nil } catch { failContract(error) } @@ -421,6 +479,7 @@ final class AddDeviceFlowStore: ObservableObject { error = nil state = .saved activeOperation = nil + activeLaneKey = nil } catch { failProfileSave(error) } @@ -438,6 +497,7 @@ final class AddDeviceFlowStore: ObservableObject { state = .failed } activeOperation = nil + activeLaneKey = nil } private func failFromResult(_ event: BackendEvent) { @@ -448,6 +508,7 @@ final class AddDeviceFlowStore: ObservableObject { ) state = .failed activeOperation = nil + activeLaneKey = nil } private func failContract(_ error: Error) { @@ -458,6 +519,7 @@ final class AddDeviceFlowStore: ObservableObject { ) state = .failed activeOperation = nil + activeLaneKey = nil } private func failProfileSave(_ error: Error) { @@ -468,6 +530,7 @@ final class AddDeviceFlowStore: ObservableObject { ) state = .failed activeOperation = nil + activeLaneKey = nil } private func failLocally(_ message: String) { @@ -478,6 +541,8 @@ final class AddDeviceFlowStore: ObservableObject { ) currentStage = nil state = .failed + activeOperation = nil + activeLaneKey = nil } private func rejectRun(_ message: String) { @@ -489,6 +554,7 @@ final class AddDeviceFlowStore: ObservableObject { currentStage = nil state = .failed activeOperation = nil + activeLaneKey = nil } private func nonNegativeDouble(_ text: String) -> Double? { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift index c444d15c..f474fa48 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift @@ -17,7 +17,7 @@ final class DashboardStore: ObservableObject { } } .store(in: &cancellables) - appStore.operationCoordinator.$activeOperation + appStore.operationCoordinator.$activeOperations .sink { [weak self] _ in Task { @MainActor in guard let self else { return } @@ -43,9 +43,9 @@ final class DashboardStore: ObservableObject { private func pruneSessions(profiles: [DeviceProfile]) { let existingIDs = Set(profiles.map(\.id)) - let activeProfileID = appStore.operationCoordinator.activeOperation?.profileID + let activeProfileIDs = Set(appStore.operationCoordinator.activeOperations.values.compactMap(\.profileID)) sessions = sessions.filter { id, _ in - existingIDs.contains(id) || id == activeProfileID + existingIDs.contains(id) || activeProfileIDs.contains(id) } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift index 397e36e8..3df361e7 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift @@ -80,6 +80,7 @@ final class DeployWorkflowStore: ObservableObject { let backend: BackendClient private let coordinator: OperationCoordinator? + private let laneKey: OperationLaneKey? private var activeOperation: ActiveOperation? private var lastProcessedEventCount = 0 @@ -92,13 +93,20 @@ final class DeployWorkflowStore: ObservableObject { init(backend: BackendClient) { self.backend = backend self.coordinator = nil + self.laneKey = nil observeBackend(backend) } - init(coordinator: OperationCoordinator) { - self.backend = coordinator.backend + convenience init(coordinator: OperationCoordinator) { + self.init(coordinator: coordinator, laneKey: .app) + } + + init(coordinator: OperationCoordinator, laneKey: OperationLaneKey) { + let lane = coordinator.lane(for: laneKey) + self.backend = lane.backend self.coordinator = coordinator - observeBackend(coordinator.backend) + self.laneKey = laneKey + observeBackend(lane.backend) } private func observeBackend(_ backend: BackendClient) { @@ -119,6 +127,10 @@ final class DeployWorkflowStore: ObservableObject { backend.isRunning } + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + var canCancel: Bool { backend.canCancel } @@ -128,7 +140,7 @@ final class DeployWorkflowStore: ObservableObject { } var canDeploy: Bool { - !backend.isRunning && state == .planReady && plan != nil && currentOptions == plannedOptions + !isBusy && state == .planReady && plan != nil && currentOptions == plannedOptions } @discardableResult @@ -137,7 +149,7 @@ final class DeployWorkflowStore: ObservableObject { failLocally(state: .planFailed, message: "Mount wait must be a non-negative integer.") return .rejected("Mount wait must be a non-negative integer.") } - guard !backend.isRunning else { + guard !isBusy else { rejectRun(state: .planFailed, message: "Another operation is already running.") return .rejected("Another operation is already running.") } @@ -186,7 +198,7 @@ final class DeployWorkflowStore: ObservableObject { guard state == .planReady else { return .rejected("Deploy plan is not ready.") } - guard !backend.isRunning else { + guard !isBusy else { rejectRun(state: .deployFailed, message: "Another operation is already running.") return .rejected("Another operation is already running.") } @@ -399,9 +411,15 @@ final class DeployWorkflowStore: ObservableObject { private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { if let coordinator { - return coordinator.run(operation: operation, params: params, profile: profile) + return coordinator.run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + laneKey: laneKey ?? profile.map { .device($0.id) } ?? .app + ) } else { - guard !backend.isRunning else { + guard !isBusy else { return .rejected("Another operation is already running.") } let context = profile?.runtimeContext diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index d2631056..2ede31b4 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -17,10 +17,15 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { private let urlOpener: URLOpening private let smbAccountResolver: SMBAccountResolving + private let lane: OperationLane private var activeCheckupOperation: ActiveOperation? private var activeDeployOperation: ActiveOperation? private var cancellables: Set = [] + var events: [BackendEvent] { + lane.backend.events + } + init( profile: DeviceProfile, appStore: AppStore, @@ -31,12 +36,16 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { self.appStore = appStore self.urlOpener = urlOpener self.smbAccountResolver = smbAccountResolver - self.deployStore = DeployWorkflowStore(coordinator: appStore.operationCoordinator) - self.doctorStore = DoctorStore(coordinator: appStore.operationCoordinator) - self.maintenanceStore = MaintenanceStore(coordinator: appStore.operationCoordinator) + let laneKey = OperationLaneKey.device(profile.id) + let lane = appStore.operationCoordinator.lane(for: laneKey) + self.lane = lane + self.deployStore = DeployWorkflowStore(coordinator: appStore.operationCoordinator, laneKey: laneKey) + self.doctorStore = DoctorStore(coordinator: appStore.operationCoordinator, laneKey: laneKey) + self.maintenanceStore = MaintenanceStore(coordinator: appStore.operationCoordinator, laneKey: laneKey) self.profileEditorStore = DeviceProfileEditorStore(profile: profile, appStore: appStore) applyProfileSettings(profile.settings) forwardChildChanges() + forwardLaneEvents() observeSnapshots() observeProfileEditor() } @@ -384,6 +393,14 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { .store(in: &cancellables) } + private func forwardLaneEvents() { + lane.backend.$events + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + private func updateCheckupSnapshot(state: DoctorWorkflowState) { guard [.passed, .warning, .failed, .runFailed].contains(state) else { return diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift index 509da291..72b0ae03 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift @@ -43,6 +43,7 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { let coordinator: OperationCoordinator let readinessStore: AppReadinessStore let registry: DeviceRegistryStore + private let lane: OperationLane private let timeout: Double private var isMonitoring = false @@ -61,6 +62,7 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { self.readinessStore = readinessStore self.registry = registry self.timeout = timeout + self.lane = coordinator.appLane readinessStore.$state .sink { [weak self] _ in @@ -69,7 +71,7 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { } } .store(in: &cancellables) - coordinator.backend.$isRunning + lane.backend.$isRunning .sink { [weak self] isRunning in guard !isRunning else { return } Task { @MainActor in @@ -77,7 +79,7 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { } } .store(in: &cancellables) - coordinator.backend.$events + lane.backend.$events .sink { [weak self] events in Task { @MainActor in self?.process(events) @@ -161,7 +163,7 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { return } - guard !coordinator.backend.isRunning else { + guard !lane.isBusy else { if activeOperation == nil { pendingRefresh = true state = .paused @@ -169,11 +171,17 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { return } - coordinator.clear() + lane.clear() lastProcessedEventCount = 0 error = nil currentStage = nil - switch coordinator.run(operation: "discover", params: OperationParams.discover(timeout: timeout), profile: nil) { + switch coordinator.run( + operation: "discover", + params: OperationParams.discover(timeout: timeout), + context: nil, + activeDeviceID: nil, + laneKey: .app + ) { case .started(let operation): activeOperation = operation state = .discovering diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift index ecd9961f..f54dcd22 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift @@ -101,6 +101,7 @@ final class DoctorStore: ObservableObject { let backend: BackendClient private let coordinator: OperationCoordinator? + private let laneKey: OperationLaneKey? private var activeOperation: ActiveOperation? private var lastProcessedEventCount = 0 @@ -113,13 +114,20 @@ final class DoctorStore: ObservableObject { init(backend: BackendClient) { self.backend = backend self.coordinator = nil + self.laneKey = nil observeBackend(backend) } - init(coordinator: OperationCoordinator) { - self.backend = coordinator.backend + convenience init(coordinator: OperationCoordinator) { + self.init(coordinator: coordinator, laneKey: .app) + } + + init(coordinator: OperationCoordinator, laneKey: OperationLaneKey) { + let lane = coordinator.lane(for: laneKey) + self.backend = lane.backend self.coordinator = coordinator - observeBackend(coordinator.backend) + self.laneKey = laneKey + observeBackend(lane.backend) } private func observeBackend(_ backend: BackendClient) { @@ -140,6 +148,10 @@ final class DoctorStore: ObservableObject { backend.isRunning } + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + var canCancel: Bool { backend.canCancel } @@ -154,7 +166,7 @@ final class DoctorStore: ObservableObject { failLocally(message: "Bonjour timeout must be a non-negative number.") return .rejected("Bonjour timeout must be a non-negative number.") } - guard !backend.isRunning else { + guard !isBusy else { rejectRun("Another operation is already running.") return .rejected("Another operation is already running.") } @@ -300,9 +312,15 @@ final class DoctorStore: ObservableObject { private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { if let coordinator { - return coordinator.run(operation: operation, params: params, profile: profile) + return coordinator.run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + laneKey: laneKey ?? profile.map { .device($0.id) } ?? .app + ) } else { - guard !backend.isRunning else { + guard !isBusy else { return .rejected("Another operation is already running.") } let context = profile?.runtimeContext diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift index e26a9633..ad0f4792 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift @@ -141,6 +141,7 @@ final class MaintenanceStore: ObservableObject { let backend: BackendClient private let coordinator: OperationCoordinator? + private let laneKey: OperationLaneKey? private var plannedUninstallOptions: MaintenanceOptions? private var plannedFsckOptions: MaintenanceOptions? @@ -157,13 +158,20 @@ final class MaintenanceStore: ObservableObject { init(backend: BackendClient) { self.backend = backend self.coordinator = nil + self.laneKey = nil observeBackend(backend) } - init(coordinator: OperationCoordinator) { - self.backend = coordinator.backend + convenience init(coordinator: OperationCoordinator) { + self.init(coordinator: coordinator, laneKey: .app) + } + + init(coordinator: OperationCoordinator, laneKey: OperationLaneKey) { + let lane = coordinator.lane(for: laneKey) + self.backend = lane.backend self.coordinator = coordinator - observeBackend(coordinator.backend) + self.laneKey = laneKey + observeBackend(lane.backend) } private func observeBackend(_ backend: BackendClient) { @@ -184,6 +192,10 @@ final class MaintenanceStore: ObservableObject { backend.isRunning } + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + var canCancel: Bool { backend.canCancel } @@ -200,19 +212,19 @@ final class MaintenanceStore: ObservableObject { } var canRunActivation: Bool { - !backend.isRunning && activationPlan != nil && activateState == .planReady + !isBusy && activationPlan != nil && activateState == .planReady } var canRunUninstall: Bool { - !backend.isRunning && uninstallPlan != nil && uninstallState == .planReady && currentOptions == plannedUninstallOptions + !isBusy && uninstallPlan != nil && uninstallState == .planReady && currentOptions == plannedUninstallOptions } var canPlanFsck: Bool { - !backend.isRunning && selectedFsckTarget != nil && currentOptions != nil + !isBusy && selectedFsckTarget != nil && currentOptions != nil } var canRunFsck: Bool { - !backend.isRunning + !isBusy && fsckPlan != nil && fsckState == .planReady && currentOptions == plannedFsckOptions @@ -220,7 +232,7 @@ final class MaintenanceStore: ObservableObject { } var canRepairXattrs: Bool { - !backend.isRunning + !isBusy && repairState == .scanReady && repairScan?.repairableCount ?? 0 > 0 && scannedRepairPath == trimmedRepairPath @@ -246,7 +258,7 @@ final class MaintenanceStore: ObservableObject { @discardableResult func runActivation(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { - guard !backend.isRunning else { + guard !isBusy else { rejectRun(workflow: .activate, message: "Another operation is already running.") return .rejected("Another operation is already running.") } @@ -299,7 +311,7 @@ final class MaintenanceStore: ObservableObject { @discardableResult func runUninstall(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { - guard !backend.isRunning else { + guard !isBusy else { rejectRun(workflow: .uninstall, message: "Another operation is already running.") return .rejected("Another operation is already running.") } @@ -395,7 +407,7 @@ final class MaintenanceStore: ObservableObject { @discardableResult func runFsck(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { - guard !backend.isRunning else { + guard !isBusy else { rejectRun(workflow: .fsck, message: "Another operation is already running.") return .rejected("Another operation is already running.") } @@ -462,7 +474,7 @@ final class MaintenanceStore: ObservableObject { @discardableResult func runRepairXattrs() -> OperationStartResult { - guard !backend.isRunning else { + guard !isBusy else { rejectRun(workflow: .repairXattrs, message: "Another operation is already running.") return .rejected("Another operation is already running.") } @@ -833,7 +845,7 @@ final class MaintenanceStore: ObservableObject { profile: DeviceProfile?, workflow: MaintenanceWorkflow ) -> OperationStartResult { - guard !backend.isRunning else { + guard !isBusy else { let message = "Another operation is already running." rejectRun(workflow: workflow, message: message) return .rejected(message) @@ -851,9 +863,15 @@ final class MaintenanceStore: ObservableObject { private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { if let coordinator { - return coordinator.run(operation: operation, params: params, profile: profile) + return coordinator.run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + laneKey: laneKey ?? profile.map { .device($0.id) } ?? .app + ) } else { - guard !backend.isRunning else { + guard !isBusy else { return .rejected("Another operation is already running.") } let context = profile?.runtimeContext diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift index 505c7533..b1abd56f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift @@ -39,45 +39,65 @@ enum OperationStartResult: Equatable { } } +enum OperationLaneKey: Hashable, Equatable, Identifiable, CustomStringConvertible { + case app + case device(DeviceProfile.ID) + case candidateHost(String) + case localPath(String) + + var id: String { + switch self { + case .app: + return "app" + case .device(let profileID): + return "device:\(profileID)" + case .candidateHost(let host): + return "candidate:\(host)" + case .localPath(let path): + return "local-path:\(path)" + } + } + + var description: String { + id + } + +} + @MainActor -final class OperationCoordinator: ObservableObject { +final class OperationLane: ObservableObject { + let key: OperationLaneKey + let backend: BackendClient + @Published private(set) var activeOperation: ActiveOperation? - @Published private(set) var activeDeviceID: DeviceProfile.ID? @Published private(set) var rejectedOperationMessage: String? - let backend: BackendClient + var onStateChanged: (() -> Void)? + private var isReplayingConfirmation = false private var cancellables: Set = [] - convenience init() { - self.init(backend: BackendClient()) - } - - init(backend: BackendClient) { + init(key: OperationLaneKey, backend: BackendClient) { + self.key = key self.backend = backend - backend.$isRunning - .sink { [weak self] isRunning in - guard !isRunning else { return } - self?.activeOperation = nil - self?.activeDeviceID = nil + + Publishers.CombineLatest(backend.$isRunning, backend.$pendingConfirmation) + .sink { [weak self] isRunning, pendingConfirmation in + guard let self else { return } + if !isRunning && pendingConfirmation == nil && !self.isReplayingConfirmation { + self.activeOperation = nil + self.onStateChanged?() + } } .store(in: &cancellables) } - @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 - ) + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + + var canCancel: Bool { + backend.canCancel } @discardableResult @@ -88,17 +108,20 @@ final class OperationCoordinator: ObservableObject { activeDeviceID: DeviceProfile.ID?, password: String? = nil ) -> OperationStartResult { - guard !backend.isRunning else { + guard !isBusy else { let message = L10n.string("operation.error.already_running") rejectedOperationMessage = message + onStateChanged?() 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, @@ -106,11 +129,26 @@ final class OperationCoordinator: ObservableObject { ) rejectedOperationMessage = nil self.activeOperation = activeOperation - self.activeDeviceID = activeDeviceID backend.run(operation: operation, params: updatedParams, context: context) + onStateChanged?() return .started(activeOperation) } + func confirmPending() { + guard backend.pendingConfirmation != nil else { + return + } + isReplayingConfirmation = true + backend.confirmPending() + isReplayingConfirmation = false + onStateChanged?() + } + + func cancelPendingConfirmation() { + backend.pendingConfirmation = nil + onStateChanged?() + } + func cancel() { backend.cancel() } @@ -119,6 +157,267 @@ final class OperationCoordinator: ObservableObject { backend.clear() rejectedOperationMessage = nil activeOperation = nil - activeDeviceID = nil + onStateChanged?() + } +} + +@MainActor +final class OperationCoordinator: ObservableObject { + @Published private(set) var activeOperations: [OperationLaneKey: ActiveOperation] = [:] + @Published private(set) var activeOperation: ActiveOperation? + @Published private(set) var activeDeviceID: DeviceProfile.ID? + @Published private(set) var rejectedOperationMessages: [OperationLaneKey: String] = [:] + @Published private(set) var rejectedOperationMessage: String? + @Published private(set) var lanesRevision = 0 + + let appLane: OperationLane + + private var lanes: [OperationLaneKey: OperationLane] = [:] + private var laneCancellables: [OperationLaneKey: Set] = [:] + private var helperPathCancellable: AnyCancellable? + + var backend: BackendClient { + appLane.backend + } + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.appLane = OperationLane(key: .app, backend: backend) + lanes[.app] = appLane + observe(lane: appLane) + helperPathCancellable = backend.$helperPath + .sink { [weak self] helperPath in + Task { @MainActor in + self?.syncHelperPath(helperPath) + } + } + } + + func lane(for key: OperationLaneKey) -> OperationLane { + if let lane = lanes[key] { + return lane + } + let lane = OperationLane(key: key, backend: backend.makeSibling()) + lanes[key] = lane + observe(lane: lane) + refreshLaneState() + return lane + } + + func lane(for profile: DeviceProfile) -> OperationLane { + lane(for: .device(profile.id)) + } + + var allLanes: [OperationLane] { + lanes.values.sorted { left, right in + laneSortKey(left.key) < laneSortKey(right.key) + } + } + + var pendingConfirmation: PendingConfirmation? { + pendingConfirmationLane?.backend.pendingConfirmation + } + + var pendingConfirmationLane: OperationLane? { + if let primary = primaryLane(), primary.backend.pendingConfirmation != nil { + return primary + } + return allLanes.first { $0.backend.pendingConfirmation != nil } + } + + var canCancel: Bool { + primaryLane()?.canCancel ?? false + } + + func activeOperation(for key: OperationLaneKey) -> ActiveOperation? { + lane(for: key).activeOperation + } + + func activeOperation(for profile: DeviceProfile) -> ActiveOperation? { + activeOperation(for: .device(profile.id)) + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + profile: DeviceProfile?, + password: String? = nil + ) -> OperationStartResult { + run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + password: password, + laneKey: profile.map { .device($0.id) } ?? .app + ) + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + laneKey: OperationLaneKey + ) -> OperationStartResult { + run( + operation: operation, + params: params, + context: nil, + activeDeviceID: nil, + laneKey: laneKey + ) + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + context: DeviceRuntimeContext?, + activeDeviceID: DeviceProfile.ID?, + password: String? = nil, + laneKey: OperationLaneKey? = nil + ) -> OperationStartResult { + let resolvedLaneKey = laneKey ?? activeDeviceID.map { .device($0) } ?? .app + let lane = lane(for: resolvedLaneKey) + let result = lane.run( + operation: operation, + params: params, + context: context, + activeDeviceID: activeDeviceID, + password: password + ) + refreshLaneState() + return result + } + + func confirmPending() { + pendingConfirmationLane?.confirmPending() + refreshLaneState() + } + + func cancelPendingConfirmation() { + pendingConfirmationLane?.cancelPendingConfirmation() + refreshLaneState() + } + + func cancel() { + primaryLane()?.cancel() + } + + func cancel(laneKey: OperationLaneKey) { + lane(for: laneKey).cancel() + } + + func clear() { + for lane in lanes.values { + lane.clear() + } + refreshLaneState() + } + + func clear(laneKey: OperationLaneKey) { + lane(for: laneKey).clear() + refreshLaneState() + } + + private func observe(lane: OperationLane) { + var cancellables: Set = [] + + lane.onStateChanged = { [weak self] in + self?.refreshLaneState() + } + lane.backend.$events + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshLaneState() + } + } + .store(in: &cancellables) + lane.backend.$isRunning + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshLaneState() + } + } + .store(in: &cancellables) + lane.backend.$activeOperationName + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshLaneState() + } + } + .store(in: &cancellables) + lane.backend.$pendingConfirmation + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshLaneState() + } + } + .store(in: &cancellables) + + laneCancellables[lane.key] = cancellables + } + + private func refreshLaneState() { + let active = lanes.compactMapValues(\.activeOperation) + activeOperations = active + rejectedOperationMessages = lanes.compactMapValues(\.rejectedOperationMessage) + rejectedOperationMessage = primaryRejection(from: rejectedOperationMessages) + let primary = primaryLane() + activeOperation = primary?.activeOperation + activeDeviceID = activeOperation?.profileID + lanesRevision += 1 + } + + private func primaryLane() -> OperationLane? { + if let runningDevice = allLanes.first(where: { lane in + lane.key != .app && lane.backend.isRunning + }) { + return runningDevice + } + if appLane.backend.isRunning { + return appLane + } + if let pendingDevice = allLanes.first(where: { lane in + lane.key != .app && lane.backend.pendingConfirmation != nil + }) { + return pendingDevice + } + if appLane.backend.pendingConfirmation != nil { + return appLane + } + return allLanes.first { $0.activeOperation != nil } + } + + private func primaryRejection(from messages: [OperationLaneKey: String]) -> String? { + if let primaryKey = primaryLane()?.key, let message = messages[primaryKey] { + return message + } + return messages.values.first + } + + private func syncHelperPath(_ helperPath: String) { + for lane in lanes.values where lane.backend !== appLane.backend { + if lane.backend.helperPath != helperPath { + lane.backend.helperPath = helperPath + } + } + } + + private func laneSortKey(_ key: OperationLaneKey) -> String { + switch key { + case .app: + return "0:app" + case .device(let profileID): + return "1:\(profileID)" + case .candidateHost(let host): + return "2:\(host)" + case .localPath(let path): + return "3:\(path)" + } } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift index 8c716157..268438ad 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift @@ -50,6 +50,30 @@ final class ActivityProgressTextAnimatorTests: XCTestCase { XCTAssertNil(ActivityProgressTextAnimator.animationIdentity(for: completed)) } + func testCompactStatusAnimationIdentityExistsOnlyForActiveMessages() { + let running = ActivityCompactStatus( + isRunning: true, + requiresAttention: false, + scope: .app, + operationTitle: "Checkup", + latestMessage: "Run local and remote diagnostic checks.", + latestTimelineTitle: "Running Checkup", + activeLaneCount: 1 + ) + let completed = ActivityCompactStatus( + isRunning: false, + requiresAttention: false, + scope: .app, + operationTitle: "Checkup", + latestMessage: "Run local and remote diagnostic checks.", + latestTimelineTitle: "Done", + activeLaneCount: 0 + ) + + XCTAssertEqual(ActivityProgressTextAnimator.animationIdentity(for: running), "Run local and remote diagnostic checks.") + XCTAssertNil(ActivityProgressTextAnimator.animationIdentity(for: completed)) + } + func testFrameIntervalMatchesBottomBarAnimationCadence() { XCTAssertEqual(ActivityProgressTextAnimator.frameInterval, 0.3) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift index d9f9e6b3..778ad5e3 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift @@ -97,6 +97,331 @@ final class ActivityStoreTests: XCTestCase { XCTAssertEqual(activity.snapshot.scope, .app) } + func testActivityStoreTracksMultipleActiveLanesAndPrefersDeviceSnapshot() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent( + type: "result", + operation: "discover", + ok: true, + payload: testDiscoverPayload(records: []) + ) + ], delayNanoseconds: 200_000_000) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "doctor", + stage: "run_checks", + description: "Run local and remote diagnostic checks." + ), + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ok", domain: "Runtime") + ])) + ], delayNanoseconds: 200_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: "discover", laneKey: .app) + coordinator.run(operation: "doctor", context: context, activeDeviceID: "device-one", laneKey: .device("device-one")) + + try await waitUntilStoreState { + activity.laneSnapshots.count == 2 && activity.laneSnapshots.allSatisfy { $0.snapshot.isRunning } + } + XCTAssertEqual(activity.snapshot.scope, .device("device-one")) + XCTAssertEqual(activity.snapshot.operationTitle, "Checkup") + XCTAssertEqual(Set(activity.laneSnapshots.map(\.laneKey)), [.app, .device("device-one")]) + } + + func testCompactStatusPrefersSelectedDeviceOverRunningStartupDiscovery() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], delayNanoseconds: 200_000_000) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "doctor", + stage: "run_checks", + description: "Run local and remote diagnostic checks." + ), + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 200_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.count == 2 + } + + let status = activity.compactStatus(for: ActivityDisplayContext( + selectedDeviceID: "device-one", + showingAddDevice: false, + showingActivity: false + )) + XCTAssertEqual(status.scope, .device("device-one")) + XCTAssertEqual(status.operationTitle, "Checkup") + XCTAssertEqual(status.activeLaneCount, 2) + } + + func testCompactStatusShowsMultipleActiveOperationsWhenNoSelectedLaneCanOwnTheBar() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 200_000_000) + ], + .init("deploy", profileID: "device-two"): [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: .object(["summary": .string("done")])) + ], delayNanoseconds: 200_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + coordinator.run( + operation: "deploy", + context: context("device-two"), + activeDeviceID: "device-two", + laneKey: .device("device-two") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.count == 2 + } + + let status = activity.compactStatus(for: .none) + XCTAssertEqual(status.scope, .unknown) + XCTAssertEqual(status.operationTitle, "2 active operations") + XCTAssertEqual(status.latestMessage, "Open Activity for details.") + XCTAssertEqual(status.activeLaneCount, 2) + } + + func testCompactStatusShowsMultipleActiveOperationsOnActivityScreenEvenWhenAppLaneRuns() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], delayNanoseconds: 200_000_000) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 200_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.count == 2 + } + + let status = activity.compactStatus(for: ActivityDisplayContext( + selectedDeviceID: nil, + showingAddDevice: false, + showingActivity: true + )) + XCTAssertEqual(status.scope, .unknown) + XCTAssertEqual(status.operationTitle, "2 active operations") + } + + func testCompactStatusShowsSelectedPendingConfirmationBeforeRunningDiscovery() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], delayNanoseconds: 200_000_000) + ], + .init("deploy", profileID: "device-one"): [ + .init(events: [ + confirmationRequiredEvent(operation: "deploy", id: "confirm-deploy") + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "deploy", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.contains { $0.laneKey == .app && $0.snapshot.isRunning } + && activity.activeLaneSnapshots.contains { $0.laneKey == .device("device-one") && $0.isPendingConfirmation } + } + + let status = activity.compactStatus(for: ActivityDisplayContext( + selectedDeviceID: "device-one", + showingAddDevice: false, + showingActivity: false + )) + XCTAssertEqual(status.scope, .device("device-one")) + XCTAssertEqual(status.operationTitle, "Install / Update") + XCTAssertTrue(status.requiresAttention) + XCTAssertFalse(status.isRunning) + } + + func testCompactStatusUsesAppLaneForAddDeviceDiscoveryUnlessConfigureIsActive() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], delayNanoseconds: 250_000_000) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 250_000_000) + ], + .init("configure", profileID: "device-two"): [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: .object(["summary": .string("configured")])) + ], delayNanoseconds: 250_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + let addDeviceContext = ActivityDisplayContext( + selectedDeviceID: nil, + showingAddDevice: true, + showingActivity: false + ) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.count == 2 + } + XCTAssertEqual(activity.compactStatus(for: addDeviceContext).scope, .app) + XCTAssertEqual(activity.compactStatus(for: addDeviceContext).operationTitle, "Discovery") + + coordinator.run( + operation: "configure", + context: context("device-two"), + activeDeviceID: "device-two", + laneKey: .device("device-two") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.contains { $0.laneKey == .device("device-two") } + } + let status = activity.compactStatus(for: addDeviceContext) + XCTAssertEqual(status.scope, .device("device-two")) + XCTAssertEqual(status.operationTitle, "Add Time Capsule") + } + + func testCompactStatusKeepsSelectedDeviceHistoryAfterStartupDiscoveryCompletes() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ]) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.isEmpty && activity.laneSnapshots.count == 2 + } + + let status = activity.compactStatus(for: ActivityDisplayContext( + selectedDeviceID: "device-one", + showingAddDevice: false, + showingActivity: false + )) + XCTAssertEqual(status.scope, .device("device-one")) + XCTAssertEqual(status.operationTitle, "Checkup") + XCTAssertEqual(status.latestTimelineTitle, "Done") + } + + func testActivityStoreSeparatesActiveAndRecentLaneSnapshots() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ]) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 200_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.map(\.laneKey) == [.device("device-one")] + && activity.recentLaneSnapshots.map(\.laneKey) == [.app] + } + } + func testSuccessfulAppValidationPresentsAppReadyWithoutDetailMessage() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ @@ -128,4 +453,32 @@ final class ActivityStoreTests: XCTestCase { XCTAssertEqual(activity.snapshot.scope, .app) XCTAssertNil(activity.snapshot.latestMessage) } + + private func context(_ profileID: String) -> DeviceRuntimeContext { + DeviceRuntimeContext( + profileID: profileID, + configURL: URL(fileURLWithPath: "/tmp/\(profileID)/.env") + ) + } + + private func doctorPayload() -> JSONValue { + testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ]) + } + + private func confirmationRequiredEvent(operation: String, id: String) -> BackendEvent { + BackendEvent( + type: "error", + operation: operation, + code: "confirmation_required", + message: "Confirm operation.", + details: .object([ + "title": .string("Confirm operation"), + "message": .string("Continue."), + "action_title": .string("Continue"), + "confirmation_id": .string(id) + ]) + ) + } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift index 5b409339..212870e2 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -299,20 +299,55 @@ final class AddDeviceFlowStoreTests: XCTestCase { BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) ], delayNanoseconds: 100_000_000) ]) + let existing = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) fixture.store.startManualEntry() fixture.store.manualHost = "10.0.0.2" fixture.store.password = "secret" - _ = fixture.store.coordinator.run(operation: "doctor", profile: nil) + _ = fixture.store.coordinator.run( + operation: "doctor", + context: existing.runtimeContext, + activeDeviceID: existing.id, + laneKey: .device(existing.id) + ) try await waitUntilStoreState { fixture.runner.calls.count == 1 } - XCTAssertTrue(fixture.store.isRunning) + XCTAssertTrue(fixture.store.coordinator.lane(for: existing).backend.isRunning) fixture.store.runConfigure() XCTAssertEqual(fixture.store.state, .failed) XCTAssertEqual(fixture.store.error?.code, "operation_rejected") - XCTAssertEqual(fixture.registry.profiles, []) + XCTAssertEqual(fixture.registry.profiles, [existing]) XCTAssertEqual(fixture.runner.calls.count, 1) - try await waitUntilStoreState { !fixture.store.isRunning } + try await waitUntilStoreState { !fixture.store.coordinator.lane(for: existing).backend.isRunning } + } + + func testManualConfigureCanRunWhileAppDiscoveryLaneIsBusy() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], delayNanoseconds: 150_000_000), + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "root@10.0.0.2")) + ]) + ]) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.coordinator.run(operation: "discover", laneKey: .app) + try await waitUntilStoreState { fixture.store.coordinator.appLane.backend.isRunning } + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.runner.calls.count == 2 } + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["discover", "configure"]) + XCTAssertTrue(fixture.store.coordinator.appLane.backend.isRunning) + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.registry.profiles.count, 1) } func testSelectedBonjourConfigureSuccessSavesProfileMetadata() async throws { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift index 9106817b..80274324 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -198,12 +198,24 @@ final class BackendClientTests: XCTestCase { let client = BackendClient(runner: runner) let coordinator = OperationCoordinator(backend: client) let context = DeviceRuntimeContext(profileID: "device-one", configURL: URL(fileURLWithPath: "/tmp/device-one/.env")) + let laneKey = OperationLaneKey.device("device-one") + let deviceLane = coordinator.lane(for: laneKey) - guard case .started(let activeOperation) = coordinator.run(operation: "doctor", context: context, activeDeviceID: "device-one") else { + guard case .started(let activeOperation) = coordinator.run( + operation: "doctor", + context: context, + activeDeviceID: "device-one", + laneKey: laneKey + ) else { XCTFail("Expected first operation to start.") return } - guard case .rejected(let rejectionMessage) = coordinator.run(operation: "deploy", context: context, activeDeviceID: "device-one") else { + guard case .rejected(let rejectionMessage) = coordinator.run( + operation: "deploy", + context: context, + activeDeviceID: "device-one", + laneKey: laneKey + ) else { XCTFail("Expected second operation to be rejected.") return } @@ -214,7 +226,7 @@ final class BackendClientTests: XCTestCase { XCTAssertEqual(coordinator.activeOperation, activeOperation) XCTAssertEqual(coordinator.activeDeviceID, "device-one") - try await waitUntil { !client.isRunning } + try await waitUntil { !deviceLane.backend.isRunning } XCTAssertNil(coordinator.activeOperation) XCTAssertNil(coordinator.activeDeviceID) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index e7f3297f..df890c6f 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -80,12 +80,12 @@ final class DashboardStoreTests: XCTestCase { let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore, urlOpener: opener) session.performPrimaryAction(.runCheckup, profile: profile) - try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") XCTAssertEqual(session.selectedTab, .checkup) session.performPrimaryAction(.installSMB, profile: profile) - try await waitUntilStoreState { fixture.runner.calls.count == 2 && !fixture.appStore.backend.isRunning } + try await waitUntilStoreState { fixture.runner.calls.count == 2 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } XCTAssertEqual(fixture.runner.calls[1].operation, "deploy") XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(true)) XCTAssertEqual(session.selectedTab, .install) @@ -294,21 +294,26 @@ final class DashboardStoreTests: XCTestCase { let session = dashboard.session(for: profile) session.runCheckup(profile: profile) - try await waitUntilStoreState { fixture.appStore.backend.isRunning } + try await waitUntilStoreState { self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } try await fixture.registry.delete(profile) XCTAssertTrue(dashboard.hasSession(for: profile.id)) - try await waitUntilStoreState { !fixture.appStore.backend.isRunning } + try await waitUntilStoreState { !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } try await waitUntilStoreState { !dashboard.hasSession(for: profile.id) } } - func testOperationRunningOnAnotherDeviceRejectsNewSessionOperation() async throws { + func testOperationRunningOnAnotherDeviceAllowsNewSessionOperation() async throws { let fixture = try await makeFixture(responses: [ .init(events: [ BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") ])) - ], delayNanoseconds: 200_000_000) + ], delayNanoseconds: 200_000_000), + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]) ]) let first = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), @@ -329,12 +334,13 @@ final class DashboardStoreTests: XCTestCase { let secondSession = dashboard.session(for: second) firstSession.runCheckup(profile: first) - try await waitUntilStoreState { fixture.appStore.backend.isRunning } + try await waitUntilStoreState { self.deviceLaneIsRunning(first, appStore: fixture.appStore) } secondSession.runCheckup(profile: second) - XCTAssertEqual(secondSession.doctorStore.state, .runFailed) - XCTAssertEqual(secondSession.doctorStore.error?.code, "operation_rejected") - XCTAssertEqual(fixture.runner.calls.count, 1) + try await waitUntilStoreState { fixture.runner.calls.count == 2 } + XCTAssertEqual(secondSession.doctorStore.state, .running) + try await waitUntilStoreState { secondSession.doctorStore.state == .passed } + XCTAssertEqual(Set(fixture.runner.calls.map { $0.context?.profileID }), ["device-one", "device-two"]) } func testDashboardOperationsUpdateLastCheckupAndDeploySnapshots() async throws { @@ -570,7 +576,7 @@ final class DashboardStoreTests: XCTestCase { error: error, profile: profile )) - try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") XCTAssertEqual(fixture.runner.calls[0].params["credentials"], .object(["password": .string("pw")])) XCTAssertEqual(session.selectedTab, .checkup) @@ -580,7 +586,7 @@ final class DashboardStoreTests: XCTestCase { error: error, profile: profile )) - try await waitUntilStoreState { fixture.runner.calls.count == 2 && !fixture.appStore.backend.isRunning } + try await waitUntilStoreState { fixture.runner.calls.count == 2 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } XCTAssertEqual(fixture.runner.calls[1].operation, "deploy") XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(true)) XCTAssertEqual(fixture.runner.calls[1].params["credentials"], .object(["password": .string("pw")])) @@ -612,7 +618,7 @@ final class DashboardStoreTests: XCTestCase { profile: profile )) - try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") XCTAssertEqual(session.selectedTab, .checkup) } @@ -669,7 +675,7 @@ final class DashboardStoreTests: XCTestCase { session.performInstallAction(.runCheckup, profile: profile) { diagnosticsShown = true } - try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") XCTAssertEqual(session.selectedTab, .checkup) @@ -701,6 +707,10 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(fixture.passwordStore.state(for: profile.keychainAccount), .missing) } + private func deviceLaneIsRunning(_ profile: DeviceProfile, appStore: AppStore) -> Bool { + appStore.operationCoordinator.lane(for: profile).backend.isRunning + } + private func makeFixture(responses: [StoreTestRunner.Response]) async throws -> ( appStore: AppStore, registry: DeviceRegistryStore, diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryMonitorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryMonitorStoreTests.swift index 51ddbd8a..5bdf2507 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryMonitorStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryMonitorStoreTests.swift @@ -98,6 +98,37 @@ final class DeviceDiscoveryMonitorStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls.map(\.operation), ["capabilities", "validate-install", "doctor", "discover"]) } + func testDeviceOperationDoesNotPauseAppDiscoveryRefresh() async throws { + let fixture = try await makeReadyFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ok", domain: "Runtime") + ])) + ], delayNanoseconds: 150_000_000), + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord(hostname: "parallel.local.") + ]))]) + ]) + let context = DeviceRuntimeContext( + profileID: "device-one", + configURL: URL(fileURLWithPath: "/tmp/device-one/.env") + ) + + fixture.coordinator.run( + operation: "doctor", + context: context, + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + try await waitUntilStoreState { fixture.coordinator.lane(for: .device("device-one")).backend.isRunning } + fixture.monitor.startMonitoring() + + XCTAssertNotEqual(fixture.monitor.state, .paused) + try await waitUntilStoreState { fixture.runner.calls.count == 4 } + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["capabilities", "validate-install", "doctor", "discover"]) + try await waitUntilStoreState { fixture.monitor.state == .ready } + } + func testReadinessBlockedPreventsDiscovery() async throws { let temp = try TemporaryDirectory() let runner = StoreTestRunner(responses: []) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift new file mode 100644 index 00000000..db01814d --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift @@ -0,0 +1,311 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class OperationCoordinatorLaneTests: XCTestCase { + func testAppAndDeviceOperationsRunInParallel() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], delayNanoseconds: 200_000_000) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 200_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let deviceContext = context("device-one") + + XCTAssertStarted(coordinator.run(operation: "discover", laneKey: .app)) + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: deviceContext, + activeDeviceID: "device-one", + laneKey: .device("device-one") + )) + + let deviceLane = coordinator.lane(for: .device("device-one")) + try await waitUntilStoreState { + runner.calls.count == 2 && coordinator.appLane.backend.isRunning && deviceLane.backend.isRunning + } + XCTAssertNil(coordinator.rejectedOperationMessage) + XCTAssertEqual(Set(coordinator.activeOperations.keys), [.app, .device("device-one")]) + + try await waitUntilStoreState { + !coordinator.appLane.backend.isRunning && !deviceLane.backend.isRunning + } + } + + func testSameDeviceLaneRejectsSecondOperation() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 200_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let laneKey = OperationLaneKey.device("device-one") + let deviceContext = context("device-one") + + XCTAssertStarted(coordinator.run(operation: "doctor", context: deviceContext, activeDeviceID: "device-one", laneKey: laneKey)) + try await waitUntilStoreState { coordinator.lane(for: laneKey).backend.isRunning && runner.calls.count == 1 } + let second = coordinator.run(operation: "deploy", context: deviceContext, activeDeviceID: "device-one", laneKey: laneKey) + + XCTAssertEqual(second.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.rejectedOperationMessages[laneKey], "Another operation is already running.") + XCTAssertEqual(runner.calls.count, 1) + } + + func testDifferentDeviceLanesRunSameOperationInParallel() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 200_000_000) + ], + .init("doctor", profileID: "device-two"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 200_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + )) + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-two"), + activeDeviceID: "device-two", + laneKey: .device("device-two") + )) + + try await waitUntilStoreState { + runner.calls.count == 2 + && coordinator.lane(for: .device("device-one")).backend.isRunning + && coordinator.lane(for: .device("device-two")).backend.isRunning + } + XCTAssertEqual(Set(runner.calls.compactMap { $0.context?.profileID }), ["device-one", "device-two"]) + XCTAssertEqual(Set(coordinator.activeOperations.keys), [.device("device-one"), .device("device-two")]) + } + + func testAppLaneRejectsSecondAppOperation() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], delayNanoseconds: 200_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + + XCTAssertStarted(coordinator.run(operation: "discover", laneKey: .app)) + try await waitUntilStoreState { coordinator.appLane.backend.isRunning } + let second = coordinator.run(operation: "capabilities", laneKey: .app) + + XCTAssertEqual(second.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.rejectedOperationMessages[.app], "Another operation is already running.") + XCTAssertEqual(runner.calls.map(\.operation), ["discover"]) + } + + func testPendingConfirmationBlocksSameLaneButNotOtherLaneAndReplayKeepsContext() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + confirmationRequiredEvent(operation: "deploy", id: "deploy-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload()) + ]) + ], + .init("doctor", profileID: "device-two"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 100_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let firstLane = OperationLaneKey.device("device-one") + + XCTAssertStarted(coordinator.run( + operation: "deploy", + params: ["dry_run": .bool(false)], + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: firstLane + )) + try await waitUntilStoreState { + coordinator.lane(for: firstLane).backend.pendingConfirmation != nil + && !coordinator.lane(for: firstLane).backend.isRunning + } + + let sameLaneResult = coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: firstLane + ) + XCTAssertEqual(sameLaneResult.rejectionMessage, "Another operation is already running.") + + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-two"), + activeDeviceID: "device-two", + laneKey: .device("device-two") + )) + try await waitUntilStoreState { runner.calls.count == 2 } + + coordinator.confirmPending() + try await waitUntilStoreState { runner.calls.count == 3 && coordinator.pendingConfirmation == nil } + XCTAssertEqual(runner.calls[2].operation, "deploy") + XCTAssertEqual(runner.calls[2].context, context("device-one")) + XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("deploy-confirm")) + } + + func testCancelOnlyCancelsTargetLane() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], delayNanoseconds: 1_000_000_000) + ], + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], delayNanoseconds: 500_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let deviceLaneKey = OperationLaneKey.device("device-one") + + XCTAssertStarted(coordinator.run(operation: "discover", laneKey: .app)) + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: deviceLaneKey + )) + try await waitUntilStoreState { + coordinator.appLane.backend.isRunning && coordinator.lane(for: deviceLaneKey).backend.isRunning + } + + coordinator.cancel(laneKey: deviceLaneKey) + + try await waitUntilStoreState { + !coordinator.lane(for: deviceLaneKey).backend.isRunning && coordinator.appLane.backend.isRunning + } + XCTAssertEqual(coordinator.lane(for: deviceLaneKey).backend.events.last?.code, "cancelled") + } + + func testClearingOneLaneDoesNotClearOtherLaneEvents() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ]) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let deviceLaneKey = OperationLaneKey.device("device-one") + + XCTAssertStarted(coordinator.run(operation: "discover", laneKey: .app)) + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: deviceLaneKey + )) + try await waitUntilStoreState { + !coordinator.appLane.backend.isRunning + && !coordinator.lane(for: deviceLaneKey).backend.isRunning + && !coordinator.appLane.backend.events.isEmpty + && !coordinator.lane(for: deviceLaneKey).backend.events.isEmpty + } + + coordinator.clear(laneKey: .app) + + XCTAssertTrue(coordinator.appLane.backend.events.isEmpty) + XCTAssertFalse(coordinator.lane(for: deviceLaneKey).backend.events.isEmpty) + } + + func testHelperPathChangesSyncToExistingAndNewLanes() async throws { + let coordinator = OperationCoordinator(backend: BackendClient(runner: StoreTestRunner(responses: []))) + let existingLane = coordinator.lane(for: .device("device-one")) + + coordinator.backend.helperPath = "/tmp/tcapsule" + + try await waitUntilStoreState { existingLane.backend.helperPath == "/tmp/tcapsule" } + XCTAssertEqual(existingLane.backend.helperPath, "/tmp/tcapsule") + XCTAssertEqual(coordinator.lane(for: .device("device-two")).backend.helperPath, "/tmp/tcapsule") + } + + func testPasswordCredentialInjectionIsScopedToStartedLane() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + password: "secret", + laneKey: .device("device-one") + )) + + try await waitUntilStoreState { runner.calls.count == 1 } + XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("secret")])) + XCTAssertEqual(runner.calls[0].context?.profileID, "device-one") + } + + private func XCTAssertStarted( + _ result: OperationStartResult, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case .started = result else { + XCTFail("Expected operation to start, got \(result).", file: file, line: line) + return + } + } + + private func context(_ profileID: String) -> DeviceRuntimeContext { + DeviceRuntimeContext( + profileID: profileID, + configURL: URL(fileURLWithPath: "/tmp/\(profileID)/.env") + ) + } + + private func doctorPayload() -> JSONValue { + testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ]) + } + + private func confirmationRequiredEvent(operation: String, id: String) -> BackendEvent { + BackendEvent( + type: "error", + operation: operation, + code: "confirmation_required", + message: "Confirm operation.", + details: .object(["confirmation_id": .string(id)]) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift index 75af6a8a..59edc568 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -70,6 +70,73 @@ final class StoreTestRunner: HelperRunning, @unchecked Sendable { } } +final class OperationKeyedStoreTestRunner: HelperRunning, @unchecked Sendable { + struct Key: Hashable, Sendable { + let operation: String + let profileID: String? + + init(_ operation: String, profileID: String? = nil) { + self.operation = operation + self.profileID = profileID + } + } + + typealias Call = StoreTestRunner.Call + typealias Response = StoreTestRunner.Response + + private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.OperationKeyedStoreTestRunner") + private var storedResponses: [Key: [Response]] + private var storedCalls: [Call] = [] + + init(responses: [Key: [Response]]) { + self.storedResponses = responses + } + + var calls: [Call] { + queue.sync { storedCalls } + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + let response = queue.sync { + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params, context: context)) + let key = Key(operation, profileID: context?.profileID) + if var responses = storedResponses[key], !responses.isEmpty { + let response = responses.removeFirst() + storedResponses[key] = responses + return response + } + let fallbackKey = Key(operation) + if var responses = storedResponses[fallbackKey], !responses.isEmpty { + let response = responses.removeFirst() + storedResponses[fallbackKey] = responses + return response + } + return Response( + events: [BackendEvent.error(operation: operation, code: "missing_test_response", message: "No keyed test response queued.")], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + } + + if response.delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: response.delayNanoseconds) + } + if Task.isCancelled { + await onEvent(BackendEvent.error(operation: operation, code: "cancelled", message: L10n.string("helper.error.cancelled"))) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + for event in response.events { + await onEvent(event) + } + return response.result + } +} + @MainActor func waitUntilStoreState( timeoutNanoseconds: UInt64 = 2_000_000_000, diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 273c4fa9..e5e5aed3 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -5,7 +5,9 @@ from pathlib import Path from typing import Callable, Optional import ipaddress +import os import re +import tempfile from timecapsulesmb.core.net import extract_host, ipv4_literal, is_link_local_ipv4 from timecapsulesmb.core.paths import package_project_root, resolve_app_paths @@ -678,4 +680,25 @@ def preserved_env_file_values(values: dict[str, str]) -> dict[str, str]: def write_env_file(path: Path, values: dict[str, str]) -> None: - path.write_text(render_env_text(values)) + text = render_env_text(values) + tmp_name: str | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as tmp: + tmp_name = tmp.name + tmp.write(text) + tmp.flush() + os.fsync(tmp.fileno()) + os.replace(tmp_name, path) + finally: + if tmp_name is not None: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass diff --git a/tests/test_config.py b/tests/test_config.py index 2375276c..ddf3643c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,6 +3,7 @@ import shlex import tempfile import unittest +from unittest import mock from pathlib import Path import sys @@ -277,6 +278,19 @@ def test_write_env_file_round_trips_configure_id(self) -> None: reparsed = parse_env_file(path) self.assertEqual(reparsed["TC_CONFIGURE_ID"], "12345678-1234-1234-1234-123456789012") + def test_write_env_file_is_atomic_when_replace_fails(self) -> None: + values = dict(DEFAULTS) + values["TC_HOST"] = "root@10.0.0.5" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".env" + path.write_text("TC_HOST='root@10.0.0.2'\n") + with mock.patch("timecapsulesmb.core.config.os.replace", side_effect=OSError("replace failed")): + with self.assertRaisesRegex(OSError, "replace failed"): + write_env_file(path, values) + + self.assertEqual(parse_env_file(path)["TC_HOST"], "root@10.0.0.2") + self.assertEqual(list(Path(tmp).glob(".env.*.tmp")), []) + def test_parse_env_value_falls_back_for_unbalanced_quotes(self) -> None: self.assertEqual(parse_env_value("'unterminated"), "unterminated") From 62b3f4d9baa1442324b2fc381c36882ff21ad74e Mon Sep 17 00:00:00 2001 From: James Chang Date: Thu, 21 May 2026 23:34:59 -0700 Subject: [PATCH 027/129] Fix macOS workflow state and settings behavior --- macos/TimeCapsuleSMB/Package.swift | 2 +- .../TimeCapsuleSMBApp/App/AppCloseGuard.swift | 199 +++++++++++++++ .../Backend/BackendClient.swift | 10 + .../Backend/OperationParams.swift | 69 +++-- .../Backend/PendingConfirmation.swift | 89 ++++++- .../Policies/DashboardActionPolicy.swift | 111 ++++++++ .../Policies/DeviceStatusPolicy.swift | 2 +- .../Profiles/DeviceProfile.swift | 35 +++ .../Profiles/DeviceProfileEditorStore.swift | 65 ++++- .../Profiles/DeviceRegistryStore.swift | 21 ++ .../Resources/en.lproj/Localizable.strings | 68 +++-- .../Components/BlockingProgressOverlay.swift | 31 ++- .../Views/Components/ErrorRecoveryView.swift | 2 +- .../OperationTimelineStateIcon.swift | 64 +++++ .../Views/Components/SharedViews.swift | 34 +++ .../Views/Dashboard/CheckupTab.swift | 43 ++-- .../Views/Dashboard/DeviceDashboardView.swift | 31 +-- .../Views/Dashboard/FlashBootHookView.swift | 2 +- .../Views/Dashboard/InstallTab.swift | 57 ++--- .../Views/Dashboard/MaintenanceTab.swift | 84 ++++--- .../Views/Dashboard/OverviewTab.swift | 51 +++- .../{AdvancedTab.swift => SettingsTab.swift} | 25 +- .../Views/Shell/ActivityView.swift | 2 +- .../Views/Shell/ContentView.swift | 12 +- .../Workflows/CheckupPresentation.swift | 2 +- .../DashboardOverviewPresentation.swift | 58 ++--- .../Workflows/DashboardPresentation.swift | 2 +- .../Workflows/DeployWorkflowStore.swift | 16 ++ .../Workflows/DeviceDashboardSession.swift | 38 ++- .../Workflows/DeviceDashboardTab.swift | 6 +- .../Workflows/FlashWorkflowStore.swift | 6 + .../Workflows/InstallPresentation.swift | 110 +++++++- .../Workflows/MaintenancePresentation.swift | 22 +- .../Workflows/MaintenanceStore.swift | 125 ++++++++- .../Workflows/OperationCoordinator.swift | 6 +- .../Workflows/OperationTimeline.swift | 11 +- .../TimeCapsuleSMBExecutable/main.swift | 2 + .../AddDeviceFlowStoreTests.swift | 2 +- .../AppCloseGuardTests.swift | 82 ++++++ .../BackendClientTests.swift | 29 +++ .../DashboardPresentationTests.swift | 238 +++++++++++++++++- .../DashboardStoreTests.swift | 67 ++++- .../DeployWorkflowStoreTests.swift | 125 ++++++++- .../DeviceProfileEditorStoreTests.swift | 66 ++++- .../DeviceProfileTests.swift | 18 ++ .../DeviceStatusPolicyTests.swift | 6 +- .../FlashWorkflowStoreTests.swift | 8 + .../MaintenanceStoreTests.swift | 151 +++++++++++ .../OperationCoordinatorLaneTests.swift | 67 ++++- .../OperationTimelineBuilderTests.swift | 34 +++ .../PendingConfirmationTests.swift | 121 ++++++++- macos/TimeCapsuleSMB/tools/package_app.py | 2 +- src/timecapsulesmb/app/confirmations.py | 8 + src/timecapsulesmb/app/ops/deploy.py | 21 +- src/timecapsulesmb/app/ops/maintenance.py | 34 ++- src/timecapsulesmb/cli/deploy.py | 2 +- src/timecapsulesmb/services/app.py | 6 + src/timecapsulesmb/services/deploy.py | 4 +- tests/test_app_api.py | 112 +++++++-- tests/test_storage_runtime.py | 15 +- 60 files changed, 2403 insertions(+), 328 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/{AdvancedTab.swift => SettingsTab.swift} (80%) create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift diff --git a/macos/TimeCapsuleSMB/Package.swift b/macos/TimeCapsuleSMB/Package.swift index b29a7506..e6ba3184 100644 --- a/macos/TimeCapsuleSMB/Package.swift +++ b/macos/TimeCapsuleSMB/Package.swift @@ -14,7 +14,7 @@ let xcodeLinkerSettings: [LinkerSetting] = xcodeFrameworkFlags.isEmpty ? [] : [. let package = Package( name: "TimeCapsuleSMBMac", defaultLocalization: "en", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v14)], products: [ .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBExecutable"]) ], diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift new file mode 100644 index 00000000..c8d5bd04 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift @@ -0,0 +1,199 @@ +import AppKit +import ObjectiveC +import SwiftUI + +enum AppCloseGuardRequest: Equatable { + case windowClose + case appQuit +} + +struct AppCloseGuardPrompt: Equatable { + let title: String + let message: String + let cancelTitle: String + let confirmTitle: String + + static var activeOperation: AppCloseGuardPrompt { + AppCloseGuardPrompt( + title: L10n.string("close_guard.title"), + message: L10n.string("close_guard.message"), + cancelTitle: L10n.string("close_guard.keep_open"), + confirmTitle: L10n.string("close_guard.close_anyway") + ) + } +} + +private struct AppCloseGuardPolicy { + var hasBlockingActivity: () -> Bool = { false } + + var requiresConfirmation: Bool { + hasBlockingActivity() + } +} + +@MainActor +protocol AppCloseGuardPresenting: AnyObject { + func confirmClose( + _ prompt: AppCloseGuardPrompt, + for request: AppCloseGuardRequest, + modalFor window: NSWindow?, + completion: @escaping @MainActor (Bool) -> Void + ) +} + +@MainActor +private final class AppCloseGuardAlertPresenter: AppCloseGuardPresenting { + func confirmClose( + _ prompt: AppCloseGuardPrompt, + for _: AppCloseGuardRequest, + modalFor window: NSWindow?, + completion: @escaping @MainActor (Bool) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = prompt.title + alert.informativeText = prompt.message + alert.addButton(withTitle: prompt.cancelTitle) + alert.addButton(withTitle: prompt.confirmTitle) + + if let window, window.isVisible { + alert.beginSheetModal(for: window) { response in + Task { @MainActor in + completion(response == .alertSecondButtonReturn) + } + } + return + } + + DispatchQueue.main.async { + let response = alert.runModal() + Task { @MainActor in + completion(response == .alertSecondButtonReturn) + } + } + } +} + +@MainActor +public final class AppCloseGuard: NSObject { + public static let shared = AppCloseGuard() + + var presenter: AppCloseGuardPresenting = AppCloseGuardAlertPresenter() + + private var policy = AppCloseGuardPolicy() + private var authorizedWindowCloses: Set = [] + + public func configure(hasBlockingActivity: @escaping () -> Bool) { + policy = AppCloseGuardPolicy(hasBlockingActivity: hasBlockingActivity) + } + + func shouldCloseWindow(_ window: NSWindow) -> Bool { + guard policy.requiresConfirmation else { + return true + } + presenter.confirmClose( + AppCloseGuardPrompt.activeOperation, + for: .windowClose, + modalFor: window + ) { [weak self, weak window] confirmed in + guard confirmed, let window else { + return + } + self?.authorizeNextClose(of: window) + window.performClose(nil) + } + return false + } + + func shouldTerminateApplication(_ application: NSApplication) -> NSApplication.TerminateReply { + guard policy.requiresConfirmation else { + return .terminateNow + } + presenter.confirmClose( + AppCloseGuardPrompt.activeOperation, + for: .appQuit, + modalFor: application.keyWindow ?? application.mainWindow + ) { confirmed in + application.reply(toApplicationShouldTerminate: confirmed) + } + return .terminateLater + } + + func attach(to window: NSWindow) { + if objc_getAssociatedObject(window, &windowCloseGuardDelegateKey) is GuardedWindowDelegate { + return + } + let delegate = GuardedWindowDelegate(downstream: window.delegate) + objc_setAssociatedObject(window, &windowCloseGuardDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + window.delegate = delegate + } + + func consumeAuthorizedClose(of window: NSWindow) -> Bool { + authorizedWindowCloses.remove(ObjectIdentifier(window)) != nil + } + + private func authorizeNextClose(of window: NSWindow) { + authorizedWindowCloses.insert(ObjectIdentifier(window)) + } +} + +@MainActor +public final class AppCloseGuardApplicationDelegate: NSObject, NSApplicationDelegate { + var closeGuard: AppCloseGuard = .shared + + public override init() { + super.init() + } + + public func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + closeGuard.shouldTerminateApplication(sender) + } +} + +private var windowCloseGuardDelegateKey: UInt8 = 0 + +private final class GuardedWindowDelegate: NSObject, NSWindowDelegate { + private weak var downstream: NSWindowDelegate? + + init(downstream: NSWindowDelegate?) { + self.downstream = downstream + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + let alreadyConfirmed = AppCloseGuard.shared.consumeAuthorizedClose(of: sender) + if let downstreamAllows = downstream?.windowShouldClose?(sender), !downstreamAllows { + return false + } + if alreadyConfirmed { + return true + } + return AppCloseGuard.shared.shouldCloseWindow(sender) + } + + func windowWillClose(_ notification: Notification) { + downstream?.windowWillClose?(notification) + } +} + +struct WindowCloseGuardInstaller: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + GuardedWindowAnchorView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let window = nsView.window else { + return + } + AppCloseGuard.shared.attach(to: window) + } + + private final class GuardedWindowAnchorView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window else { + return + } + AppCloseGuard.shared.attach(to: window) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift index 19b2a1bc..0abec024 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift @@ -97,6 +97,16 @@ final class BackendClient: ObservableObject { run(operation: confirmation.operation, params: confirmation.params, context: confirmation.context) } + func cancelPendingConfirmation() { + guard let confirmation = pendingConfirmation, !isRunning else { return } + pendingConfirmation = nil + events.append(BackendEvent.error( + operation: confirmation.operation, + code: "confirmation_cancelled", + message: L10n.string("helper.error.cancelled") + )) + } + fileprivate func appendEvent(_ event: BackendEvent) { if event.type == "stage" { currentStage = event.stage diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift index 01801b27..7e1067e2 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift @@ -1,5 +1,14 @@ import Foundation +struct RepairXattrsOptions: Equatable { + var recursive: Bool = true + var maxDepth: Int? + var includeHidden: Bool = false + var includeTimeMachine: Bool = false + var fixPermissions: Bool = false + var verbose: Bool = false +} + enum OperationParams { private static func rootSSHTarget(_ host: String) -> String { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) @@ -27,19 +36,25 @@ enum OperationParams { host: String = "", selectedRecord: JSONValue? = nil, password: String, - debugLogging: Bool + debugLogging: Bool, + internalShareUseDiskRoot: Bool? = nil, + anyProtocol: Bool? = nil ) -> [String: JSONValue] { var params: [String: JSONValue] = [ "password": .string(password), - "persist_password": .bool(false) + "persist_password": .bool(false), + "debug_logging": .bool(debugLogging) ] if let selectedRecord { params["selected_record"] = selectedRecord } else { params["host"] = .string(rootSSHTarget(host)) } - if debugLogging { - params["debug_logging"] = .bool(true) + if let internalShareUseDiskRoot { + params["internal_share_use_disk_root"] = .bool(internalShareUseDiskRoot) + } + if let anyProtocol { + params["any_protocol"] = .bool(anyProtocol) } return params } @@ -69,20 +84,16 @@ enum OperationParams { mountWait: Double, password: String ) -> [String: JSONValue] { - var params: [String: JSONValue] = [ + let params: [String: JSONValue] = [ "dry_run": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "nbns_enabled": .bool(nbnsEnabled), + "internal_share_use_disk_root": .bool(internalShareUseDiskRoot), + "any_protocol": .bool(anyProtocol), "debug_logging": .bool(debugLogging), "mount_wait": .number(mountWait) ] - if internalShareUseDiskRoot { - params["internal_share_use_disk_root"] = .bool(true) - } - if anyProtocol { - params["any_protocol"] = .bool(true) - } return withCredentials(params, password: password) } @@ -96,20 +107,16 @@ enum OperationParams { mountWait: Double, password: String ) -> [String: JSONValue] { - var params: [String: JSONValue] = [ + let params: [String: JSONValue] = [ "dry_run": .bool(false), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "nbns_enabled": .bool(nbnsEnabled), + "internal_share_use_disk_root": .bool(internalShareUseDiskRoot), + "any_protocol": .bool(anyProtocol), "debug_logging": .bool(debugLogging), "mount_wait": .number(mountWait) ] - if internalShareUseDiskRoot { - params["internal_share_use_disk_root"] = .bool(true) - } - if anyProtocol { - params["any_protocol"] = .bool(true) - } return withCredentials(params, password: password) } @@ -165,17 +172,27 @@ enum OperationParams { ], password: password) } - static func repairXattrsScan(path: String) -> [String: JSONValue] { - [ - "path": .string(path), - "dry_run": .bool(true) - ] + static func repairXattrsScan(path: String, options: RepairXattrsOptions = RepairXattrsOptions()) -> [String: JSONValue] { + repairXattrsParams(path: path, dryRun: true, options: options) + } + + static func repairXattrsRun(path: String, options: RepairXattrsOptions = RepairXattrsOptions()) -> [String: JSONValue] { + repairXattrsParams(path: path, dryRun: false, options: options) } - static func repairXattrsRun(path: String) -> [String: JSONValue] { - [ + private static func repairXattrsParams(path: String, dryRun: Bool, options: RepairXattrsOptions) -> [String: JSONValue] { + var params: [String: JSONValue] = [ "path": .string(path), - "dry_run": .bool(false) + "dry_run": .bool(dryRun), + "recursive": .bool(options.recursive), + "include_hidden": .bool(options.includeHidden), + "include_time_machine": .bool(options.includeTimeMachine), + "fix_permissions": .bool(options.fixPermissions), + "verbose": .bool(options.verbose) ] + if let maxDepth = options.maxDepth { + params["max_depth"] = .number(Double(maxDepth)) + } + return params } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift index 6bc01193..c07fbfc9 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift @@ -23,9 +23,17 @@ struct PendingConfirmation: Identifiable { return nil } - self.title = Self.detailString(details, "title") ?? L10n.string("confirm.backend.title") - self.message = Self.detailString(details, "message") ?? event.message ?? L10n.string("confirm.backend.message") - self.actionTitle = Self.detailString(details, "action_title") ?? L10n.string("action.confirm") + let presentation = ConfirmationPresentation(details: details) + self.title = presentation?.title + ?? Self.detailString(details, "title") + ?? L10n.string("confirm.backend.title") + self.message = presentation?.message + ?? Self.detailString(details, "message") + ?? event.message + ?? L10n.string("confirm.backend.message") + self.actionTitle = presentation?.actionTitle + ?? Self.detailString(details, "action_title") + ?? L10n.string("action.confirm") self.operation = event.operation var confirmedParams = originalParams confirmedParams["confirmation_id"] = .string(confirmationId) @@ -41,3 +49,78 @@ struct PendingConfirmation: Identifiable { return trimmed.isEmpty ? nil : value } } + +private struct ConfirmationPresentation { + let title: String + let message: String + let actionTitle: String + + init?(details: [String: JSONValue]) { + guard + let presentationKey = Self.detailString(details, "presentation_id"), + let title = Self.localizedString("confirm.\(presentationKey).title"), + let message = Self.localizedMessage(for: presentationKey, details: details) + else { + return nil + } + self.title = title + self.message = message + self.actionTitle = Self.localizedString("confirm.\(presentationKey).action") + ?? Self.detailString(details, "action_title") + ?? L10n.string("action.confirm") + } + + private static func localizedMessage(for presentationKey: String, details: [String: JSONValue]) -> String? { + let messageKey = "confirm.\(presentationKey).message" + guard let template = localizedString(messageKey) else { + return nil + } + let values = detailObject(details, "presentation_values") + switch presentationKey { + case "deploy.netbsd4", "deploy.no_reboot", "deploy.reboot": + guard let deviceName = stringValue(values, "device_name") else { + return nil + } + return format(template, deviceName) + case "repair_xattrs": + guard let path = stringValue(values, "path") else { + return nil + } + return format(template, path) + default: + return template + } + } + + private static func localizedString(_ key: String) -> String? { + let value = L10n.string(key) + return value == key ? nil : value + } + + private static func format(_ template: String, _ arguments: CVarArg...) -> String { + String(format: template, locale: Locale.current, arguments: arguments) + } + + private static func detailString(_ details: [String: JSONValue], _ key: String) -> String? { + guard case .string(let value)? = details[key] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : value + } + + private static func detailObject(_ details: [String: JSONValue], _ key: String) -> [String: JSONValue] { + guard case .object(let values)? = details[key] else { + return [:] + } + return values + } + + private static func stringValue(_ values: [String: JSONValue], _ key: String) -> String? { + guard case .string(let value)? = values[key] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : value + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift new file mode 100644 index 00000000..6c293f3c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift @@ -0,0 +1,111 @@ +import Foundation + +enum DashboardActionPolicy { + static func secondaryActions(for summary: DeviceDashboardSummary) -> [DashboardSecondaryAction] { + var actions: [DashboardSecondaryAction] = [] + if let contextualAction = contextualSecondaryAction(for: summary.primaryAction) { + actions.append(contextualAction) + } + if summary.profile.lastDeploy != nil && summary.primaryAction != .openSMB { + actions.append(.openFinder) + } + if !requiresPasswordReplacement(summary.passwordState) { + actions.append(.replacePassword) + } + actions.append(.settings) + return removingDuplicates(actions.filter { isAvailable($0, for: summary) }) + } + + static func requiresPasswordReplacement(_ passwordState: DevicePasswordState) -> Bool { + switch passwordState { + case .unknown, .missing, .invalid, .keychainUnavailable: + return true + case .available: + return false + } + } + + static func isEnabled(_ action: DashboardPrimaryAction, for summary: DeviceDashboardSummary) -> Bool { + !blocksMutatingActions(summary.displayStatus) || !action.isMutatingOverviewAction + } + + static func isEnabled(_ action: DashboardSecondaryAction, for summary: DeviceDashboardSummary) -> Bool { + !blocksMutatingActions(summary.displayStatus) || !action.isMutatingOverviewAction + } + + static func checkupAction(for summary: DeviceDashboardSummary) -> DashboardSecondaryAction { + summary.displayStatus == .checking ? .viewCheckup : .runCheckup + } + + private static func contextualSecondaryAction(for primaryAction: DashboardPrimaryAction) -> DashboardSecondaryAction? { + switch primaryAction { + case .replacePassword: + return .runCheckup + case .runCheckup: + return .installUpdate + case .installSMB, .viewCheckup, .openSMB: + return .runCheckup + } + } + + private static func isAvailable(_ action: DashboardSecondaryAction, for summary: DeviceDashboardSummary) -> Bool { + switch action { + case .runCheckup: + return summary.displayStatus != .checking + case .installUpdate, + .openFinder, + .replacePassword, + .viewCheckup, + .startSMB, + .settings: + return true + } + } + + private static func blocksMutatingActions(_ status: DeviceDisplayStatus) -> Bool { + switch status { + case .checking, .installing, .maintaining: + return true + case .unchecked, + .passwordNeeded, + .passwordInvalid, + .keychainUnavailable, + .readyToInstall, + .healthy, + .warning, + .failed, + .activationNeeded, + .removed, + .offline, + .unsupported: + return false + } + } + + private static func removingDuplicates(_ actions: [DashboardSecondaryAction]) -> [DashboardSecondaryAction] { + var seen: Set = [] + return actions.filter { seen.insert($0).inserted } + } +} + +private extension DashboardPrimaryAction { + var isMutatingOverviewAction: Bool { + switch self { + case .runCheckup, .installSMB: + return true + case .replacePassword, .viewCheckup, .openSMB: + return false + } + } +} + +private extension DashboardSecondaryAction { + var isMutatingOverviewAction: Bool { + switch self { + case .runCheckup, .installUpdate: + return true + case .openFinder, .replacePassword, .viewCheckup, .startSMB, .settings: + return false + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift index 65000438..681feb75 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift @@ -63,7 +63,7 @@ enum DeviceDisplayStatus: String, CaseIterable, Equatable, Identifiable { case .checking: return "stethoscope" case .installing: - return "square.and.arrow.up" + return "square.and.arrow.down.on.square" case .maintaining: return "wrench.and.screwdriver" case .readyToInstall: diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift index 34f63ed7..8464fa00 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift @@ -35,14 +35,49 @@ enum DevicePasswordState: String, Codable, CaseIterable, Equatable { struct DeviceProfileSettings: Codable, Equatable { var nbnsEnabled: Bool + var internalShareUseDiskRoot: Bool + var anyProtocol: Bool var debugLogging: Bool var mountWaitSeconds: Int static let `default` = DeviceProfileSettings( nbnsEnabled: true, + internalShareUseDiskRoot: false, + anyProtocol: false, debugLogging: false, mountWaitSeconds: 30 ) + + init( + nbnsEnabled: Bool, + internalShareUseDiskRoot: Bool = false, + anyProtocol: Bool = false, + debugLogging: Bool, + mountWaitSeconds: Int + ) { + self.nbnsEnabled = nbnsEnabled + self.internalShareUseDiskRoot = internalShareUseDiskRoot + self.anyProtocol = anyProtocol + self.debugLogging = debugLogging + self.mountWaitSeconds = mountWaitSeconds + } + + private enum CodingKeys: String, CodingKey { + case nbnsEnabled + case internalShareUseDiskRoot + case anyProtocol + case debugLogging + case mountWaitSeconds + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + nbnsEnabled = try container.decodeIfPresent(Bool.self, forKey: .nbnsEnabled) ?? Self.default.nbnsEnabled + internalShareUseDiskRoot = try container.decodeIfPresent(Bool.self, forKey: .internalShareUseDiskRoot) ?? Self.default.internalShareUseDiskRoot + anyProtocol = try container.decodeIfPresent(Bool.self, forKey: .anyProtocol) ?? Self.default.anyProtocol + debugLogging = try container.decodeIfPresent(Bool.self, forKey: .debugLogging) ?? Self.default.debugLogging + mountWaitSeconds = try container.decodeIfPresent(Int.self, forKey: .mountWaitSeconds) ?? Self.default.mountWaitSeconds + } } struct DeviceCheckupSnapshot: Codable, Equatable { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift index c3ba7faf..11079f2c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift @@ -60,6 +60,8 @@ struct DeviceProfileEditorDraft: Equatable { var displayName: String var host: String var nbnsEnabled: Bool + var internalShareUseDiskRoot: Bool + var anyProtocol: Bool var debugLogging: Bool var mountWaitSeconds: String @@ -67,12 +69,16 @@ struct DeviceProfileEditorDraft: Equatable { displayName: String, host: String, nbnsEnabled: Bool, + internalShareUseDiskRoot: Bool = false, + anyProtocol: Bool = false, debugLogging: Bool, mountWaitSeconds: String ) { self.displayName = displayName self.host = host self.nbnsEnabled = nbnsEnabled + self.internalShareUseDiskRoot = internalShareUseDiskRoot + self.anyProtocol = anyProtocol self.debugLogging = debugLogging self.mountWaitSeconds = mountWaitSeconds } @@ -82,6 +88,8 @@ struct DeviceProfileEditorDraft: Equatable { displayName: profile.displayName, host: profile.host, nbnsEnabled: profile.settings.nbnsEnabled, + internalShareUseDiskRoot: profile.settings.internalShareUseDiskRoot, + anyProtocol: profile.settings.anyProtocol, debugLogging: profile.settings.debugLogging, mountWaitSeconds: String(profile.settings.mountWaitSeconds) ) @@ -101,6 +109,8 @@ struct DeviceProfileEditorDraft: Equatable { } return DeviceProfileSettings( nbnsEnabled: nbnsEnabled, + internalShareUseDiskRoot: internalShareUseDiskRoot, + anyProtocol: anyProtocol, debugLogging: debugLogging, mountWaitSeconds: mountWait ) @@ -126,6 +136,7 @@ final class DeviceProfileEditorStore: ObservableObject { private let coordinator: OperationCoordinator private let lane: OperationLane private let profileSaver: ConfiguredDeviceProfileSaving + private var baselineDraft: DeviceProfileEditorDraft private var activeOperation: ActiveOperation? private var pendingProfile: DeviceProfile? private var pendingDraft: DeviceProfileEditorDraft? @@ -139,7 +150,9 @@ final class DeviceProfileEditorStore: ObservableObject { appStore: AppStore, profileSaver: ConfiguredDeviceProfileSaving? = nil ) { - self.draft = DeviceProfileEditorDraft(profile: profile) + let initialDraft = DeviceProfileEditorDraft(profile: profile) + self.draft = initialDraft + self.baselineDraft = initialDraft self.appStore = appStore self.coordinator = appStore.operationCoordinator self.lane = appStore.operationCoordinator.lane(for: profile) @@ -154,12 +167,38 @@ final class DeviceProfileEditorStore: ObservableObject { state == .saving || state == .reconfiguring } - func canSave(profile: DeviceProfile) -> Bool { - !isRunning && draft != DeviceProfileEditorDraft(profile: profile) + var canSave: Bool { + !isRunning && draft != baselineDraft + } + + func sync(to profile: DeviceProfile) { + let profileDraft = DeviceProfileEditorDraft(profile: profile) + guard profileDraft != baselineDraft else { + return + } + + let wasClean = draft == baselineDraft + baselineDraft = profileDraft + guard !isRunning else { + return + } + + if wasClean { + applyDraft(profileDraft) + validationErrors = [] + error = nil + currentStage = nil + savedProfile = nil + state = .clean + } else { + updateDraftChangeState() + } } func reset(to profile: DeviceProfile) { - applyDraft(DeviceProfileEditorDraft(profile: profile)) + let profileDraft = DeviceProfileEditorDraft(profile: profile) + baselineDraft = profileDraft + applyDraft(profileDraft) validationErrors = [] error = nil currentStage = nil @@ -222,7 +261,9 @@ final class DeviceProfileEditorStore: ObservableObject { do { let saved = try await appStore.saveProfileEdits(profile: profile, fields: draft.editableFields()) savedProfile = saved - applyDraft(DeviceProfileEditorDraft(profile: saved)) + let savedDraft = DeviceProfileEditorDraft(profile: saved) + baselineDraft = savedDraft + applyDraft(savedDraft) state = .saved } catch { failSave(error) @@ -233,7 +274,9 @@ final class DeviceProfileEditorStore: ObservableObject { let params = OperationParams.configure( host: draft.trimmedHost, password: password, - debugLogging: draft.debugLogging + debugLogging: draft.debugLogging, + internalShareUseDiskRoot: draft.internalShareUseDiskRoot, + anyProtocol: draft.anyProtocol ) let start = coordinator.run( operation: "configure", @@ -328,7 +371,9 @@ final class DeviceProfileEditorStore: ObservableObject { ) ) savedProfile = saved - applyDraft(DeviceProfileEditorDraft(profile: saved)) + let savedDraft = DeviceProfileEditorDraft(profile: saved) + baselineDraft = savedDraft + applyDraft(savedDraft) error = nil validationErrors = [] currentStage = nil @@ -403,9 +448,13 @@ final class DeviceProfileEditorStore: ObservableObject { guard !isApplyingDraft, !isRunning else { return } + updateDraftChangeState() + } + + private func updateDraftChangeState() { error = nil validationErrors = [] savedProfile = nil - state = .dirty + state = draft == baselineDraft ? .clean : .dirty } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift index b9559775..d8cd4f84 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift @@ -182,6 +182,12 @@ final class DeviceRegistryStore: ObservableObject { } } + func clearDeploy(for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.clearDeploy(for: profileID) + } + } + func profile(id: DeviceProfile.ID?) -> DeviceProfile? { guard let id else { return nil @@ -434,6 +440,21 @@ private actor DeviceRegistryRepository { return updatedProfiles } + func clearDeploy(for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return nil + } + guard profiles[index].lastDeploy != nil else { + return nil + } + var updatedProfiles = profiles + updatedProfiles[index].lastDeploy = nil + updatedProfiles[index].updatedAt = now() + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles + } + private func matchingProfile(host: String, bonjourFullname: String?) -> DeviceProfile? { let normalizedFullname = normalizedBonjourFullname(bonjourFullname) if let normalizedFullname, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 733f6f88..48351b8b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -18,7 +18,7 @@ "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.progress.configuring.message" = "Verifying access and preparing this Time Capsule. This can take a few seconds."; +"add_device.progress.configuring.message" = "Verifying access and preparing this Time Capsule. This can take a few seconds..."; "add_device.progress.configuring.title" = "Connecting to Time Capsule"; "add_device.progress.discovering.message" = "Browsing for nearby AirPort Bonjour services..."; "add_device.progress.discovering.title" = "Discovering Time Capsules"; @@ -85,35 +85,44 @@ "checkup.presentation.row.info" = "Info"; "checkup.presentation.row.pass" = "Pass"; "checkup.presentation.row.warning" = "Warning"; -"confirm.activate.message" = "This will restart the deployed Samba runtime on an older NetBSD 4 device."; -"confirm.activate.title" = "Activate NetBSD 4 Runtime?"; -"confirm.backend.message" = "Confirm this operation."; -"confirm.backend.title" = "Confirm Operation"; -"confirm.deploy.no_reboot.message" = "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device."; +"close_guard.close_anyway" = "Close Anyway"; +"close_guard.keep_open" = "Keep Open"; +"close_guard.message" = "TimeCapsuleSMB has an operation in progress. Closing now may interrupt work on the Time Capsule."; +"close_guard.title" = "Close TimeCapsuleSMB?"; +"confirm.activate.netbsd4.action" = "Activate"; +"confirm.activate.netbsd4.message" = "Activate the deployed NetBSD4 payload and restart managed services?"; +"confirm.activate.netbsd4.title" = "Activate NetBSD4 Runtime?"; +"confirm.backend.message" = "Continue with this operation?"; +"confirm.backend.title" = "Confirm Operation?"; +"confirm.deploy.netbsd4.action" = "Deploy and Activate"; +"confirm.deploy.netbsd4.message" = "Deploy and activate the NetBSD4 payload on this %@ and change remote services?"; +"confirm.deploy.netbsd4.title" = "Deploy And Activate NetBSD4?"; +"confirm.deploy.no_reboot.action" = "Deploy"; +"confirm.deploy.no_reboot.message" = "Deploy TimeCapsuleSMB to this %@ without rebooting it?"; "confirm.deploy.no_reboot.title" = "Deploy Without Reboot?"; -"confirm.deploy.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.action" = "Deploy and Reboot"; +"confirm.deploy.reboot.message" = "Deploy TimeCapsuleSMB and reboot this %@?"; "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.no_reboot.action" = "Run fsck"; +"confirm.fsck.no_reboot.message" = "Run fsck on the selected HFS volume?"; +"confirm.fsck.no_reboot.title" = "Run Disk Repair?"; +"confirm.fsck.reboot.action" = "Run fsck"; +"confirm.fsck.reboot.message" = "Run fsck on the selected HFS volume and reboot the device?"; "confirm.fsck.reboot.title" = "Run Disk Repair And Reboot?"; -"confirm.repair_xattrs.message" = "This will repair extended attributes at the selected mounted SMB path."; +"confirm.repair_xattrs.action" = "Repair xattrs"; +"confirm.repair_xattrs.message" = "Repair known-safe macOS metadata issues under %@?"; "confirm.repair_xattrs.title" = "Repair Extended Attributes?"; -"confirm.uninstall.no_reboot.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.no_reboot.action" = "Uninstall"; +"confirm.uninstall.no_reboot.message" = "Remove managed TimeCapsuleSMB files from the device?"; +"confirm.uninstall.no_reboot.title" = "Uninstall?"; +"confirm.uninstall.reboot.action" = "Uninstall"; +"confirm.uninstall.reboot.message" = "Remove managed TimeCapsuleSMB files from the device and reboot it?"; "confirm.uninstall.reboot.title" = "Uninstall And Reboot?"; "dashboard.action.install_smb" = "Install SMB"; "dashboard.action.install_update_smb" = "Install / Update SMB"; -"dashboard.action.advanced" = "Advanced"; +"dashboard.action.settings" = "Settings"; "dashboard.action.open_finder" = "Open Finder"; -"dashboard.action.open_smb" = "Open SMB Address"; +"dashboard.action.open_smb" = "Open Finder"; "dashboard.action.replace_password" = "Replace Password"; "dashboard.action.run_checkup" = "Run Checkup"; "dashboard.action.save_password" = "Save Password"; @@ -151,7 +160,7 @@ "dashboard.overview.status" = "Status"; "dashboard.password.title" = "Saved Password"; "dashboard.replacement_password" = "Update saved password"; -"dashboard.tab.advanced" = "Advanced"; +"dashboard.tab.settings" = "Settings"; "dashboard.tab.checkup" = "Checkup"; "dashboard.tab.install" = "Install / Update"; "dashboard.tab.maintenance" = "Maintenance"; @@ -179,6 +188,7 @@ "deploy.result.verified" = "Verified"; "install.action.create_plan" = "Create Install Plan"; "install.action.install_update" = "Install / Update"; +"install.action.reinstall" = "Reinstall"; "install.action.regenerate_plan" = "Regenerate Plan"; "install.advanced_options" = "Advanced Options"; "install.completion.title.finished" = "Install / Update Finished"; @@ -196,7 +206,7 @@ "install.plan.section.target" = "Target"; "install.plan.title.netbsd4" = "Install / Update SMB and Start Runtime"; "install.plan.title.standard" = "Install / Update SMB"; -"install.progress.deploying.message" = "Uploading and applying the managed SMB runtime. This can take a few seconds."; +"install.progress.deploying.message" = "Uploading and applying the managed SMB runtime. This can take a few minutes..."; "install.progress.deploying.title" = "Installing / Updating SMB"; "install.state.awaiting_confirmation" = "Review the confirmation dialog before continuing."; "install.state.deploy_failed" = "Install / Update failed."; @@ -220,7 +230,7 @@ "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.message" = "Remove %@ from this Mac? This does not uninstall SMB from the Time Capsule."; "dialog.forget.title" = "Forget Time Capsule?"; "doctor.domain.connection" = "Connection"; "doctor.domain.disk" = "Disk"; @@ -244,6 +254,7 @@ "field.host" = "Host"; "field.mount_wait" = "Mount wait seconds"; "field.password" = "Password"; +"field.repair_xattrs_max_depth" = "Max depth"; "field.repair_xattrs_path" = "Repair xattrs path"; "flash.action.backup_inspect" = "Back Up and Inspect"; "flash.action.patch_boot_hook" = "Patch Boot Hook"; @@ -415,7 +426,7 @@ "recovery.action.run_checkup" = "Run Checkup"; "recovery.action.start_smb" = "Activate"; "recovery.action.uninstall" = "Uninstall"; -"screen.advanced" = "Advanced"; +"screen.settings" = "Settings"; "screen.connect" = "Connect"; "screen.deploy" = "Deploy"; "screen.doctor" = "Doctor"; @@ -485,6 +496,11 @@ "toggle.force_debug_logging" = "Force Debug Logging"; "toggle.no_reboot" = "No Reboot"; "toggle.no_wait" = "No Wait"; +"toggle.repair_xattrs_fix_permissions" = "Fix Permissions"; +"toggle.repair_xattrs_include_hidden" = "Include Hidden Paths"; +"toggle.repair_xattrs_include_time_machine" = "Include Time Machine Paths"; +"toggle.repair_xattrs_recursive" = "Recursive"; +"toggle.repair_xattrs_verbose" = "Verbose Output"; "toolbar.cancel" = "Cancel"; "toolbar.add" = "Add"; "toolbar.clear" = "Clear"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift index 7272c2b2..958dc273 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift @@ -2,12 +2,20 @@ import SwiftUI struct BlockingProgressOverlay: View { let progress: Progress + let allowsBackgroundInteraction: Bool + + init(progress: Progress, allowsBackgroundInteraction: Bool = false) { + self.progress = progress + self.allowsBackgroundInteraction = allowsBackgroundInteraction + } var body: some View { ZStack { - Color.clear - .contentShape(Rectangle()) - .ignoresSafeArea() + if !allowsBackgroundInteraction { + Color.clear + .contentShape(Rectangle()) + .ignoresSafeArea() + } VStack(spacing: 12) { ProgressView() @@ -19,20 +27,23 @@ struct BlockingProgressOverlay: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - if let detail = progress.detail, !detail.isEmpty { - Text(detail) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(2) - .multilineTextAlignment(.center) - } + Text(progress.detail ?? "") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(minHeight: 28, alignment: .top) + .opacity(progress.detail?.isEmpty == false ? 1 : 0) } .padding(22) .frame(width: 340) + .frame(minHeight: 176) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 8)) .shadow(radius: 18) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .allowsHitTesting(!allowsBackgroundInteraction) .transition(.opacity) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift index 60db485e..f4d3caa8 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift @@ -59,7 +59,7 @@ struct ErrorRecoveryView: View { case .runCheckup: return "stethoscope" case .installSMB: - return "square.and.arrow.up" + return "square.and.arrow.down.on.square" case .startSMB: return "play.circle" case .uninstall: diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift new file mode 100644 index 00000000..55fd3255 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct OperationTimelineStateIcon: View { + let state: OperationTimelineItem.State + + var body: some View { + icon + .frame(width: 16, height: 16) + .accessibilityLabel(accessibilityLabel) + } + + @ViewBuilder + private var icon: some View { + switch state { + case .pending: + Image(systemName: "circle") + case .running: + RotatingTimelineIcon() + case .succeeded: + Image(systemName: "checkmark.circle") + case .warning: + Image(systemName: "exclamationmark.triangle") + case .failed: + Image(systemName: "xmark.octagon") + } + } + + private var accessibilityLabel: String { + switch state { + case .pending: + return "Pending" + case .running: + return "Running" + case .succeeded: + return "Succeeded" + case .warning: + return "Warning" + case .failed: + return "Failed" + } + } +} + +private struct RotatingTimelineIcon: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isRotating = false + + var body: some View { + Image(systemName: "arrow.triangle.2.circlepath") + .rotationEffect(.degrees(!reduceMotion && isRotating ? 360 : 0)) + .animation(animation, value: isRotating) + .onAppear { + guard !reduceMotion else { return } + isRotating = true + } + .onDisappear { + isRotating = false + } + } + + private var animation: Animation? { + reduceMotion ? nil : .linear(duration: 1).repeatForever(autoreverses: false) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift index a3df09bf..1e952360 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift @@ -54,3 +54,37 @@ struct StageLine: View { } } } + +struct DashboardDisclosureSection: View { + let title: String + @ViewBuilder let content: () -> Content + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + isExpanded.toggle() + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .frame(width: 12) + Text(title) + Spacer(minLength: 0) + } + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + content() + .padding(.top, 8) + .padding(.leading, 18) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift index 9933139b..cd58ad79 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift @@ -57,10 +57,9 @@ struct CheckupTab: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .disabled(progress != nil) if let progress { - BlockingProgressOverlay(progress: progress) + BlockingProgressOverlay(progress: progress, allowsBackgroundInteraction: true) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -107,8 +106,7 @@ private struct CheckupTimelineView: View { .font(.headline) ForEach(items) { item in HStack(alignment: .top, spacing: 8) { - Image(systemName: icon(for: item.state)) - .frame(width: 16) + OperationTimelineStateIcon(state: item.state) VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.body.weight(.medium)) @@ -122,21 +120,6 @@ private struct CheckupTimelineView: View { } } } - - private func icon(for state: OperationTimelineItem.State) -> String { - switch state { - case .pending: - return "circle" - case .running: - return "progress.indicator" - case .succeeded: - return "checkmark.circle" - case .warning: - return "exclamationmark.triangle" - case .failed: - return "xmark.octagon" - } - } } private struct CheckupDomainView: View { @@ -145,8 +128,13 @@ private struct CheckupDomainView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - Label(domain.title, systemImage: domain.status.systemImage) - .font(.headline) + HStack(spacing: 6) { + Image(systemName: domain.status.systemImage) + .foregroundStyle(iconColor(for: domain.status)) + .accessibilityLabel(domain.status.title) + Text(domain.title) + } + .font(.headline) Spacer() Text(domain.countSummary) .font(.caption) @@ -154,8 +142,9 @@ private struct CheckupDomainView: View { } ForEach(domain.rows) { row in HStack(alignment: .top, spacing: 8) { - Label(row.status.title, systemImage: row.status.systemImage) - .labelStyle(.iconOnly) + Image(systemName: row.status.systemImage) + .foregroundStyle(iconColor(for: row.status)) + .accessibilityLabel(row.status.title) .frame(width: 16) Text(row.statusText) .font(.system(.caption, design: .monospaced)) @@ -169,13 +158,17 @@ private struct CheckupDomainView: View { .background(Color.secondary.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 6)) } + + private func iconColor(for status: CheckupStatusPresentation) -> Color { + status == .passed ? .green : .primary + } } private struct CheckupAdvancedOptionsView: View { @ObservedObject var store: DoctorStore var body: some View { - DisclosureGroup(L10n.string("checkup.advanced_options")) { + DashboardDisclosureSection(title: L10n.string("checkup.advanced_options")) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { Text(L10n.string("field.bonjour_timeout")) @@ -192,7 +185,7 @@ private struct CheckupAdvancedOptionsView: View { EmptyView() } } - .padding(.top, 8) } + .disabled(store.isRunning) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift index 8fb0f5ca..dde4daa6 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift @@ -18,24 +18,25 @@ struct DeviceDashboardView: View { Divider() - ScrollView { - Group { - switch session.selectedTab { - case .overview: - OverviewTab(profile: profile, session: session, appStore: appStore) - case .install: - InstallTab(profile: profile, session: session, showDiagnostics: showDiagnostics) - case .checkup: - CheckupTab(profile: profile, session: session, showDiagnostics: showDiagnostics) - case .maintenance: - MaintenanceTab(profile: profile, session: session, showDiagnostics: showDiagnostics) - case .advanced: - AdvancedTab(profile: profile, session: session, appStore: appStore) + Group { + switch session.selectedTab { + case .overview: + OverviewTab(profile: profile, session: session, appStore: appStore) + case .install: + InstallTab(profile: profile, session: session, showDiagnostics: showDiagnostics) + case .checkup: + CheckupTab(profile: profile, session: session, showDiagnostics: showDiagnostics) + case .maintenance: + MaintenanceTab(profile: profile, session: session, showDiagnostics: showDiagnostics) + case .settings: + ScrollView { + SettingsTab(profile: profile, session: session, appStore: appStore) + .frame(maxWidth: .infinity, alignment: .leading) } } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift index 04610949..22654a16 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift @@ -31,7 +31,7 @@ struct FlashBootHookSection: View { .onAppear { store.refresh(profile: profile) } - .onChange(of: profile.id) { _ in + .onChange(of: profile.id) { _, _ in store.refresh(profile: profile) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift index 5ee5ee18..454ce56f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift @@ -7,6 +7,7 @@ struct InstallTab: View { var body: some View { let store = session.deployStore + let summary = session.summary(for: profile) let presentation = InstallWorkflowPresentation( state: store.state, plan: store.plan, @@ -15,7 +16,8 @@ struct InstallTab: View { events: store.events, currentStage: store.currentStage, profile: profile, - hostWarning: HostCompatibilityPolicy.warning() + hostWarning: HostCompatibilityPolicy.warning(), + isCheckupRunning: summary.displayStatus == .checking ) let progress = InstallProgressPresentation(state: store.state, currentStage: store.currentStage) @@ -46,12 +48,15 @@ struct InstallTab: View { } if let completion = presentation.completion { - InstallCompletionView(presentation: completion) { action in + InstallCompletionView( + presentation: completion, + isDisabled: { isDisabled($0, store: store) } + ) { action in session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) } } - InstallAdvancedOptionsView(store: store) + InstallExecutionOptionsView(store: store) if let error = store.error { ErrorRecoveryView(error: error) { action in @@ -61,10 +66,9 @@ struct InstallTab: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .disabled(progress != nil) if let progress { - BlockingProgressOverlay(progress: progress) + BlockingProgressOverlay(progress: progress, allowsBackgroundInteraction: true) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -79,14 +83,7 @@ struct InstallTab: View { } private func isDisabled(_ action: InstallUserAction, store: DeployWorkflowStore) -> Bool { - switch action { - case .createPlan, .regeneratePlan: - return store.isRunning || store.mountWaitValue == nil - case .installUpdate: - return !store.canDeploy - case .openFinder, .runCheckup, .viewDiagnostics: - return false - } + !InstallActionAvailabilityPolicy.isEnabled(action, store: store) } } @@ -164,8 +161,7 @@ private struct InstallTimelineView: View { } else { ForEach(presentation.items) { item in HStack(alignment: .top, spacing: 8) { - Image(systemName: icon(for: item.state)) - .frame(width: 16) + OperationTimelineStateIcon(state: item.state) VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.body.weight(.medium)) @@ -180,25 +176,11 @@ private struct InstallTimelineView: View { } } } - - private func icon(for state: OperationTimelineItem.State) -> String { - switch state { - case .pending: - return "circle" - case .running: - return "progress.indicator" - case .succeeded: - return "checkmark.circle" - case .warning: - return "exclamationmark.triangle" - case .failed: - return "xmark.octagon" - } - } } private struct InstallCompletionView: View { let presentation: InstallCompletionPresentation + let isDisabled: (InstallUserAction) -> Bool let performAction: (InstallUserAction) -> Void var body: some View { @@ -218,26 +200,19 @@ private struct InstallCompletionView: View { } label: { Label(action.title, systemImage: action.systemImage) } + .disabled(isDisabled(action)) } } } } } -private struct InstallAdvancedOptionsView: View { +private struct InstallExecutionOptionsView: View { @ObservedObject var store: DeployWorkflowStore var body: some View { - DisclosureGroup(L10n.string("install.advanced_options")) { + DashboardDisclosureSection(title: L10n.string("install.advanced_options")) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.nbnsEnabled) - Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $store.internalShareUseDiskRoot) - } - GridRow { - Toggle(L10n.string("toggle.any_protocol"), isOn: $store.anyProtocol) - Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.debugLogging) - } GridRow { Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) @@ -249,7 +224,7 @@ private struct InstallAdvancedOptionsView: View { .frame(width: 150) } } - .padding(.top, 8) } + .disabled(store.isBusy) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift index d25c018a..230299e5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift @@ -30,7 +30,9 @@ struct MaintenanceTab: View { } ) - FlashBootHookSection(profile: profile) + if FlashBootHookVisibilityPolicy.isVisible(for: profile) { + FlashBootHookSection(profile: profile) + } if let error = store.error { ErrorRecoveryView(error: error) { action in @@ -170,7 +172,7 @@ private struct MaintenanceDetailView: View { MaintenanceCompletionView(presentation: completion) } - MaintenanceAdvancedOptionsView(store: store) + MaintenanceAdvancedOptionsView(workflow: presentation.workflow, store: store) } .padding(10) .background(Color.secondary.opacity(0.06)) @@ -192,7 +194,9 @@ private struct MaintenanceDetailView: View { return !store.canRunFsck case .repairMetadata: return !store.canRepairXattrs - case .planActivation, .planUninstall, .findVolumes, .scanMetadata, .viewDiagnostics: + case .scanMetadata: + return !store.canScanRepairXattrs + case .planActivation, .planUninstall, .findVolumes, .viewDiagnostics: return false } } @@ -279,8 +283,7 @@ private struct MaintenanceTimelineView: View { .font(.headline) ForEach(presentation.items) { item in HStack(alignment: .top, spacing: 8) { - Image(systemName: icon(for: item.state)) - .frame(width: 16) + OperationTimelineStateIcon(state: item.state) VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.body.weight(.medium)) @@ -294,41 +297,64 @@ private struct MaintenanceTimelineView: View { } } } +} + +private struct MaintenanceAdvancedOptionsView: View { + let workflow: MaintenanceWorkflow + @ObservedObject var store: MaintenanceStore - private func icon(for state: OperationTimelineItem.State) -> String { - switch state { - case .pending: - return "circle" - case .running: - return "progress.indicator" - case .succeeded: - return "checkmark.circle" - case .warning: - return "exclamationmark.triangle" - case .failed: - return "xmark.octagon" + var body: some View { + DashboardDisclosureSection(title: L10n.string("maintenance.advanced_options")) { + if workflow == .repairXattrs { + RepairXattrsAdvancedOptionsView(store: store) + } else { + RemoteMaintenanceAdvancedOptionsView(store: store) + } } } } -private struct MaintenanceAdvancedOptionsView: View { +private struct RemoteMaintenanceAdvancedOptionsView: View { + @ObservedObject var store: MaintenanceStore + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.mountWait) + .frame(width: 150) + } + GridRow { + Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + } + } + } +} + +private struct RepairXattrsAdvancedOptionsView: View { @ObservedObject var store: MaintenanceStore var body: some View { - DisclosureGroup(L10n.string("maintenance.advanced_options")) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Text(L10n.string("field.mount_wait")) + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Toggle(L10n.string("toggle.repair_xattrs_recursive"), isOn: $store.repairRecursive) + Toggle(L10n.string("toggle.repair_xattrs_include_hidden"), isOn: $store.repairIncludeHidden) + } + GridRow { + Toggle(L10n.string("toggle.repair_xattrs_include_time_machine"), isOn: $store.repairIncludeTimeMachine) + Toggle(L10n.string("toggle.repair_xattrs_fix_permissions"), isOn: $store.repairFixPermissions) + } + GridRow { + Toggle(L10n.string("toggle.repair_xattrs_verbose"), isOn: $store.repairVerbose) + HStack { + Text(L10n.string("field.repair_xattrs_max_depth")) .foregroundStyle(.secondary) - TextField(L10n.string("field.mount_wait"), text: $store.mountWait) - .frame(width: 150) - } - GridRow { - Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) - Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + TextField(L10n.string("field.repair_xattrs_max_depth"), text: $store.repairMaxDepth) + .frame(width: 80) } } - .padding(.top, 8) } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift index 9a755e8d..33cdf155 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift @@ -1,5 +1,11 @@ import SwiftUI +private enum OverviewLayout { + static let actionIconSize: CGFloat = 16 + static let healthRowMinHeight: CGFloat = 64 + static let healthStatusIconSize: CGFloat = 18 +} + struct OverviewTab: View { let profile: DeviceProfile @ObservedObject var session: DeviceDashboardSession @@ -22,7 +28,9 @@ struct OverviewTab: View { DashboardPrimaryActionStrip( primaryAction: presentation.primaryAction, + isPrimaryActionEnabled: presentation.isPrimaryActionEnabled, secondaryActions: presentation.secondaryActions, + isSecondaryActionEnabled: presentation.isEnabled, performPrimary: { session.performPrimaryAction(presentation.primaryAction, profile: profile) }, @@ -42,7 +50,7 @@ struct OverviewTab: View { VStack(alignment: .leading, spacing: 10) { ForEach(presentation.healthSections) { section in - DashboardHealthSectionView(section: section) { action in + DashboardHealthSectionView(section: section, isActionEnabled: presentation.isEnabled) { action in session.performSecondaryAction(action, profile: profile) } } @@ -88,7 +96,12 @@ private struct StatusBadge: View { let status: DeviceDisplayStatus var body: some View { - Label(status.title, systemImage: status.systemImage) + Label { + Text(status.title) + } icon: { + Image(systemName: status.systemImage) + .frame(width: OverviewLayout.actionIconSize, height: OverviewLayout.actionIconSize) + } .font(.caption.weight(.medium)) .padding(.horizontal, 8) .padding(.vertical, 5) @@ -99,20 +112,24 @@ private struct StatusBadge: View { private struct DashboardPrimaryActionStrip: View { let primaryAction: DashboardPrimaryAction + let isPrimaryActionEnabled: Bool let secondaryActions: [DashboardSecondaryAction] + let isSecondaryActionEnabled: (DashboardSecondaryAction) -> Bool let performPrimary: () -> Void let performSecondary: (DashboardSecondaryAction) -> Void var body: some View { HStack(spacing: 8) { DashboardPrimaryActionButton(action: primaryAction, perform: performPrimary) + .disabled(!isPrimaryActionEnabled) ForEach(secondaryActions) { action in Button { performSecondary(action) } label: { - Label(action.title, systemImage: action.systemImage) + DashboardActionLabel(title: action.title, systemImage: action.systemImage) } + .disabled(!isSecondaryActionEnabled(action)) } } } @@ -124,12 +141,27 @@ private struct DashboardPrimaryActionButton: View { var body: some View { Button(action: perform) { - Label(action.title, systemImage: action.systemImage) + DashboardActionLabel(title: action.title, systemImage: action.systemImage) } .buttonStyle(.borderedProminent) } } +private struct DashboardActionLabel: View { + let title: String + let systemImage: String + + var body: some View { + Label { + Text(title) + .lineLimit(1) + } icon: { + Image(systemName: systemImage) + .frame(width: OverviewLayout.actionIconSize, height: OverviewLayout.actionIconSize) + } + } +} + private struct PasswordReplacementView: View { let profile: DeviceProfile @ObservedObject var session: DeviceDashboardSession @@ -160,6 +192,7 @@ private struct PasswordReplacementView: View { private struct DashboardHealthSectionView: View { let section: DashboardHealthSection + let isActionEnabled: (DashboardSecondaryAction) -> Bool let performAction: (DashboardSecondaryAction) -> Void var body: some View { @@ -168,10 +201,10 @@ private struct DashboardHealthSectionView: View { .font(.headline) ForEach(section.rows) { row in HStack(alignment: .top, spacing: 10) { - Label(row.status.title, systemImage: row.status.systemImage) + Image(systemName: row.status.systemImage) .font(.caption.weight(.medium)) - .labelStyle(.iconOnly) - .frame(width: 18) + .accessibilityLabel(row.status.title) + .frame(width: OverviewLayout.healthStatusIconSize, height: OverviewLayout.healthStatusIconSize) VStack(alignment: .leading, spacing: 3) { HStack { Text(row.title) @@ -181,9 +214,10 @@ private struct DashboardHealthSectionView: View { Button { performAction(action) } label: { - Label(action.title, systemImage: action.systemImage) + DashboardActionLabel(title: action.title, systemImage: action.systemImage) } .controlSize(.small) + .disabled(!isActionEnabled(action)) } } Text(row.detail) @@ -192,6 +226,7 @@ private struct DashboardHealthSectionView: View { } } .padding(10) + .frame(maxWidth: .infinity, minHeight: OverviewLayout.healthRowMinHeight, alignment: .topLeading) .background(Color.secondary.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 6)) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift similarity index 80% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift index 2136869b..2611594a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/AdvancedTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift @@ -1,13 +1,13 @@ import SwiftUI -struct AdvancedTab: View { +struct SettingsTab: View { let profile: DeviceProfile @ObservedObject var session: DeviceDashboardSession @ObservedObject var appStore: AppStore var body: some View { VStack(alignment: .leading, spacing: 12) { - Text(L10n.string("dashboard.tab.advanced")) + Text(L10n.string("dashboard.tab.settings")) .font(.title2.weight(.semibold)) DeviceProfileEditorView(profile: profile, store: session.profileEditorStore) SummaryGrid(rows: [ @@ -48,11 +48,14 @@ private struct DeviceProfileEditorView: View { TextField(L10n.string("field.mount_wait"), text: $store.draft.mountWaitSeconds) .frame(width: 160) } - } - - HStack { - Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) - Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) + GridRow { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) + Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $store.draft.internalShareUseDiskRoot) + } + GridRow { + Toggle(L10n.string("toggle.any_protocol"), isOn: $store.draft.anyProtocol) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) + } } HStack { @@ -63,7 +66,7 @@ private struct DeviceProfileEditorView: View { } label: { Label(L10n.string("profile_editor.save"), systemImage: "square.and.arrow.down") } - .disabled(!store.canSave(profile: profile)) + .disabled(!store.canSave) Button { store.reset(to: profile) @@ -89,6 +92,12 @@ private struct DeviceProfileEditorView: View { ErrorRecoveryView(error: error) { _ in } } } + .onAppear { + store.sync(to: profile) + } + .onChange(of: profile) { _, profile in + store.sync(to: profile) + } .padding(.bottom, 8) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift index f9c9c278..d98a5e53 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift @@ -29,7 +29,7 @@ struct ActivityCompactView: View { .padding(.horizontal) .padding(.vertical, 8) .background(Color.secondary.opacity(0.06)) - .onChange(of: ActivityProgressTextAnimator.animationIdentity(for: status)) { _ in + .onChange(of: ActivityProgressTextAnimator.animationIdentity(for: status)) { _, _ in messageAnimationPhase = 0 } .onReceive(messageAnimationTimer) { _ in diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift index 2f35808c..65fb36de 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift @@ -77,10 +77,14 @@ public struct ContentView: View { } } .frame(minWidth: 1080, minHeight: 720) + .background(WindowCloseGuardInstaller()) + .onAppear { + configureCloseGuard() + } .task { await appStore.start() } - .onChange(of: addDeviceStore.savedProfile) { profile in + .onChange(of: addDeviceStore.savedProfile) { _, profile in guard let profile else { return } appStore.select(profile) } @@ -178,6 +182,12 @@ public struct ContentView: View { return appStore.operationCoordinator.lane(for: profile).isBusy } + private func configureCloseGuard() { + AppCloseGuard.shared.configure { [weak appStore] in + appStore?.operationCoordinator.hasActiveWork ?? false + } + } + private var sidebarSelection: Binding { Binding( get: { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift index a9ab70bb..33f127db 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift @@ -32,7 +32,7 @@ enum CheckupUserAction: String, Equatable, Identifiable { case .runCheckup: return "stethoscope" case .installUpdate: - return "square.and.arrow.up" + return "square.and.arrow.down.on.square" case .startSMB: return "play.circle" case .replacePassword: diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift index 0573e89f..409cddf9 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift @@ -1,13 +1,13 @@ import Foundation -enum DashboardSecondaryAction: String, Equatable, Hashable, Identifiable { +enum DashboardSecondaryAction: String, CaseIterable, Equatable, Hashable, Identifiable { case runCheckup case installUpdate case openFinder case replacePassword case viewCheckup case startSMB - case advanced + case settings var id: String { rawValue } @@ -25,8 +25,8 @@ enum DashboardSecondaryAction: String, Equatable, Hashable, Identifiable { return L10n.string("dashboard.action.view_checkup") case .startSMB: return L10n.string("dashboard.action.start_smb") - case .advanced: - return L10n.string("dashboard.action.advanced") + case .settings: + return L10n.string("dashboard.action.settings") } } @@ -35,7 +35,7 @@ enum DashboardSecondaryAction: String, Equatable, Hashable, Identifiable { case .runCheckup: return "stethoscope" case .installUpdate: - return "square.and.arrow.up" + return "square.and.arrow.down.on.square" case .openFinder: return "folder" case .replacePassword: @@ -44,7 +44,7 @@ enum DashboardSecondaryAction: String, Equatable, Hashable, Identifiable { return "list.bullet.clipboard" case .startSMB: return "play.circle" - case .advanced: + case .settings: return "gearshape" } } @@ -169,51 +169,29 @@ struct DashboardHealthSection: Equatable, Identifiable { struct DeviceDashboardOverviewPresentation: Equatable { let header: DeviceDashboardHeaderPresentation let primaryAction: DashboardPrimaryAction + let isPrimaryActionEnabled: Bool let secondaryActions: [DashboardSecondaryAction] + let disabledSecondaryActions: Set let healthSections: [DashboardHealthSection] let hostWarning: HostCompatibilityWarning? let requiresPasswordReplacement: Bool init(summary: DeviceDashboardSummary, currentCheckupSummary: DoctorSummary? = nil) { + let secondaryActions = DashboardActionPolicy.secondaryActions(for: summary) self.header = DeviceDashboardHeaderPresentation(summary: summary) self.primaryAction = summary.primaryAction - self.secondaryActions = Self.secondaryActions(for: summary) + self.isPrimaryActionEnabled = DashboardActionPolicy.isEnabled(summary.primaryAction, for: summary) + self.secondaryActions = secondaryActions + self.disabledSecondaryActions = Set(DashboardSecondaryAction.allCases.filter { + !DashboardActionPolicy.isEnabled($0, for: summary) + }) self.healthSections = Self.healthSections(for: summary, currentCheckupSummary: currentCheckupSummary) self.hostWarning = summary.hostWarning - self.requiresPasswordReplacement = Self.requiresPasswordReplacement(summary.passwordState) + self.requiresPasswordReplacement = DashboardActionPolicy.requiresPasswordReplacement(summary.passwordState) } - private static func secondaryActions(for summary: DeviceDashboardSummary) -> [DashboardSecondaryAction] { - var actions: [DashboardSecondaryAction] = [] - switch summary.primaryAction { - case .replacePassword: - actions.append(.runCheckup) - case .runCheckup: - actions.append(.installUpdate) - case .installSMB: - actions.append(.runCheckup) - case .viewCheckup: - actions.append(.runCheckup) - case .openSMB: - actions.append(.runCheckup) - } - if summary.profile.lastDeploy != nil && summary.primaryAction != .openSMB { - actions.append(.openFinder) - } - if !requiresPasswordReplacement(summary.passwordState) { - actions.append(.replacePassword) - } - actions.append(.advanced) - return actions.removingDuplicates() - } - - private static func requiresPasswordReplacement(_ passwordState: DevicePasswordState) -> Bool { - switch passwordState { - case .unknown, .missing, .invalid, .keychainUnavailable: - return true - case .available: - return false - } + func isEnabled(_ action: DashboardSecondaryAction) -> Bool { + !disabledSecondaryActions.contains(action) } private static func healthSections( @@ -363,7 +341,7 @@ struct DeviceDashboardOverviewPresentation: Equatable { title: DashboardHealthDomain.checkup.title, detail: L10n.string("dashboard.health.unchecked"), status: .unknown, - action: .runCheckup + action: DashboardActionPolicy.checkupAction(for: summary) ) } return DashboardHealthRow( diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift index 944856b9..0fb3b2a5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift @@ -29,7 +29,7 @@ enum DashboardPrimaryAction: String, Equatable { case .runCheckup: return "stethoscope" case .installSMB: - return "square.and.arrow.up" + return "square.and.arrow.down.on.square" case .viewCheckup: return "list.bullet.clipboard" case .openSMB: diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift index 3df361e7..44ba775f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift @@ -359,6 +359,10 @@ final class DeployWorkflowStore: ObservableObject { state = .awaitingConfirmation return } + if event.code == "confirmation_cancelled" { + applyConfirmationCancelled() + return + } if event.code == "auth_failed" { passwordInvalidProfileID = activeOperation?.profileID } @@ -367,6 +371,18 @@ final class DeployWorkflowStore: ObservableObject { activeOperation = nil } + private func applyConfirmationCancelled() { + error = nil + currentStage = nil + activeOperation = nil + guard plan != nil else { + state = .idle + return + } + state = .planReady + reconcilePlanFreshness() + } + private func applyFailureResult(_ event: BackendEvent) { error = BackendErrorViewModel( operation: "deploy", diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index 2ede31b4..13d29519 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -20,6 +20,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { private let lane: OperationLane private var activeCheckupOperation: ActiveOperation? private var activeDeployOperation: ActiveOperation? + private var activeUninstallOperation: ActiveOperation? private var cancellables: Set = [] var events: [BackendEvent] { @@ -84,14 +85,14 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { case .startSMB: selectedTab = .maintenance maintenanceStore.selectedWorkflow = .activate - case .advanced: - selectedTab = .advanced + case .settings: + selectedTab = .settings } } func performInstallAction(_ action: InstallUserAction, profile: DeviceProfile, showDiagnostics: () -> Void) { switch action { - case .createPlan, .regeneratePlan: + case .createPlan, .regeneratePlan, .reinstall: runInstallPlan(profile: profile) case .installUpdate: runInstall(profile: profile) @@ -99,6 +100,8 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { openSMBAddress(for: profile) case .runCheckup: runCheckup(profile: profile) + case .viewCheckup: + selectedTab = .checkup case .viewDiagnostics: showDiagnostics() } @@ -138,7 +141,9 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } case .runUninstall: if let password = maintenancePassword(for: profile) { - maintenanceStore.runUninstall(password: password, profile: profile) + if case .started(let operation) = maintenanceStore.runUninstall(password: password, profile: profile) { + activeUninstallOperation = operation + } } case .findVolumes: if let password = maintenancePassword(for: profile) { @@ -276,6 +281,8 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { func applyProfileSettings(_ settings: DeviceProfileSettings) { deployStore.nbnsEnabled = settings.nbnsEnabled + deployStore.internalShareUseDiskRoot = settings.internalShareUseDiskRoot + deployStore.anyProtocol = settings.anyProtocol deployStore.debugLogging = settings.debugLogging deployStore.mountWait = String(settings.mountWaitSeconds) maintenanceStore.mountWait = String(settings.mountWaitSeconds) @@ -323,6 +330,13 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } } .store(in: &cancellables) + maintenanceStore.$uninstallState + .sink { [weak self] state in + Task { @MainActor in + self?.updateUninstallSnapshot(state: state) + } + } + .store(in: &cancellables) } private func observeProfileEditor() { @@ -449,4 +463,20 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { ), for: profile.id) } } + + private func updateUninstallSnapshot(state: MaintenanceOperationState) { + guard [.succeeded, .failed].contains(state) else { + return + } + defer { + activeUninstallOperation = nil + } + guard state == .succeeded, + let profileID = activeUninstallOperation?.profileID else { + return + } + Task { + await appStore.deviceRegistry.clearDeploy(for: profileID) + } + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift index 99509dd0..03bda0d0 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift @@ -5,7 +5,7 @@ enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { case install case checkup case maintenance - case advanced + case settings var id: String { rawValue } @@ -19,8 +19,8 @@ enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { return L10n.string("dashboard.tab.checkup") case .maintenance: return L10n.string("dashboard.tab.maintenance") - case .advanced: - return L10n.string("dashboard.tab.advanced") + case .settings: + return L10n.string("dashboard.tab.settings") } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift index d2269042..7a345e73 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift @@ -103,6 +103,12 @@ enum FlashEligibilityPolicy { } } +enum FlashBootHookVisibilityPolicy { + static func isVisible(for profile: DeviceProfile) -> Bool { + profile.traits.supportsFlashBootHook + } +} + @MainActor final class FlashWorkflowStore: ObservableObject { @Published private(set) var state: FlashWorkflowState = .disabledInThisBuild diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift index ea2a8f58..616ed32e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift @@ -61,8 +61,10 @@ enum InstallUserAction: String, Equatable, Identifiable { case createPlan case regeneratePlan case installUpdate + case reinstall case openFinder case runCheckup + case viewCheckup case viewDiagnostics var id: String { rawValue } @@ -75,10 +77,14 @@ enum InstallUserAction: String, Equatable, Identifiable { return L10n.string("install.action.regenerate_plan") case .installUpdate: return L10n.string("install.action.install_update") + case .reinstall: + return L10n.string("install.action.reinstall") case .openFinder: return L10n.string("dashboard.action.open_finder") case .runCheckup: return L10n.string("dashboard.action.run_checkup") + case .viewCheckup: + return L10n.string("dashboard.action.view_checkup") case .viewDiagnostics: return L10n.string("recovery.action.open_diagnostics") } @@ -89,17 +95,43 @@ enum InstallUserAction: String, Equatable, Identifiable { case .createPlan, .regeneratePlan: return "doc.text.magnifyingglass" case .installUpdate: - return "square.and.arrow.up" + return "square.and.arrow.down.on.square" + case .reinstall: + return "arrow.clockwise" case .openFinder: return "folder" case .runCheckup: return "stethoscope" + case .viewCheckup: + return "list.bullet.clipboard" case .viewDiagnostics: return "wrench.and.screwdriver" } } } +enum InstallCompletionActionPolicy { + static func actions(isCheckupRunning: Bool) -> [InstallUserAction] { + [.reinstall, .openFinder, isCheckupRunning ? .viewCheckup : .runCheckup, .viewDiagnostics] + } +} + +enum InstallActionAvailabilityPolicy { + @MainActor + static func isEnabled(_ action: InstallUserAction, store: DeployWorkflowStore) -> Bool { + switch action { + case .createPlan, .regeneratePlan, .reinstall: + return !store.isBusy && store.mountWaitValue != nil + case .installUpdate: + return store.canDeploy + case .runCheckup: + return !store.isBusy + case .openFinder, .viewCheckup, .viewDiagnostics: + return true + } + } +} + struct InstallTimelinePresentation: Equatable { let items: [OperationTimelineItem] @@ -129,21 +161,45 @@ struct InstallCompletionPresentation: Equatable { let warnings: [String] let actions: [InstallUserAction] - init(result: DeployResultPayload) { - self.title = result.verified == true + init(result: DeployResultPayload, isCheckupRunning: Bool = false) { + self.init( + verified: result.verified, + rebootRequested: result.rebootRequested, + message: result.message ?? result.summary, + netbsd4: result.netbsd4, + isCheckupRunning: isCheckupRunning + ) + } + + init(snapshot: DeviceDeploySnapshot, profile: DeviceProfile, isCheckupRunning: Bool = false) { + self.init( + verified: snapshot.verified, + rebootRequested: snapshot.rebootRequested, + message: snapshot.summary, + netbsd4: Self.isNetBSD4(snapshot: snapshot, profile: profile), + isCheckupRunning: isCheckupRunning + ) + } + + private init(verified: Bool?, rebootRequested: Bool?, message: String, netbsd4: Bool, isCheckupRunning: Bool) { + self.title = verified == true ? L10n.string("install.completion.title.verified") : L10n.string("install.completion.title.finished") self.rows = [ - PresentationRow(label: L10n.string("deploy.result.verified"), value: result.verified == true ? L10n.string("value.yes") : L10n.string("value.no")), - PresentationRow(label: L10n.string("deploy.result.reboot_requested"), value: result.rebootRequested == true ? L10n.string("value.yes") : L10n.string("value.no")), - PresentationRow(label: L10n.string("deploy.result.message"), value: result.message ?? result.summary) + PresentationRow(label: L10n.string("deploy.result.verified"), value: verified == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.reboot_requested"), value: rebootRequested == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.message"), value: message) ] var warnings: [String] = [] - if result.netbsd4 { + if netbsd4 { warnings.append(L10n.string("install.completion.warning.netbsd4")) } self.warnings = warnings - self.actions = [.openFinder, .runCheckup, .viewDiagnostics] + self.actions = InstallCompletionActionPolicy.actions(isCheckupRunning: isCheckupRunning) + } + + private static func isNetBSD4(snapshot: DeviceDeploySnapshot, profile: DeviceProfile) -> Bool { + snapshot.payloadFamily?.localizedCaseInsensitiveContains("netbsd4") == true || profile.traits.isNetBSD4 } } @@ -189,18 +245,31 @@ struct InstallWorkflowPresentation: Equatable { events: [BackendEvent], currentStage: OperationStageState?, profile: DeviceProfile, - hostWarning: HostCompatibilityWarning? = nil + hostWarning: HostCompatibilityWarning? = nil, + isCheckupRunning: Bool = false ) { self.title = L10n.string("dashboard.tab.install") - self.stateTitle = state.title self.plan = plan.map { InstallPlanPresentation(plan: $0, profile: profile, hostWarning: hostWarning) } self.timeline = Self.timeline(for: state, events: events, currentStage: currentStage) - self.completion = result.map(InstallCompletionPresentation.init) + let persistedCompletion = Self.persistedCompletion( + state: state, + result: result, + profile: profile, + isCheckupRunning: isCheckupRunning + ) + self.completion = result.map { InstallCompletionPresentation(result: $0, isCheckupRunning: isCheckupRunning) } + ?? persistedCompletion + self.stateTitle = persistedCompletion == nil ? state.title : DeployWorkflowState.deployed.title switch state { case .idle: - self.statusMessage = L10n.string("install.state.idle") - self.primaryAction = .createPlan + if persistedCompletion == nil { + self.statusMessage = L10n.string("install.state.idle") + self.primaryAction = .createPlan + } else { + self.statusMessage = L10n.string("install.state.deployed") + self.primaryAction = nil + } self.notices = [] case .planning: self.statusMessage = L10n.string("install.state.planning") @@ -237,6 +306,21 @@ struct InstallWorkflowPresentation: Equatable { } } + private static func persistedCompletion( + state: DeployWorkflowState, + result: DeployResultPayload?, + profile: DeviceProfile, + isCheckupRunning: Bool + ) -> InstallCompletionPresentation? { + guard state == .idle, + result == nil, + let snapshot = profile.lastDeploy, + snapshot.state == .deployed else { + return nil + } + return InstallCompletionPresentation(snapshot: snapshot, profile: profile, isCheckupRunning: isCheckupRunning) + } + private static func timeline( for state: DeployWorkflowState, events: [BackendEvent], diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift index 5d5147c4..2a796d84 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift @@ -222,8 +222,8 @@ struct MaintenanceWorkflowDetailPresentation: Equatable { let timeline: MaintenanceTimelinePresentation? @MainActor - init(store: MaintenanceStore, profile: DeviceProfile) { - let workflow = store.selectedWorkflow + init(store: MaintenanceStore, profile: DeviceProfile, workflow selectedWorkflow: MaintenanceWorkflow? = nil) { + let workflow = selectedWorkflow ?? store.selectedWorkflow let legacy = MaintenanceWorkflowPresentation.presentation(for: workflow) let state = store.state(for: workflow) self.workflow = workflow @@ -369,23 +369,35 @@ struct MaintenanceWorkflowDetailPresentation: Equatable { } } +enum MaintenanceWorkflowAvailability { + static func workflows(for profile: DeviceProfile) -> [MaintenanceWorkflow] { + MaintenanceWorkflow.allCases.filter { workflow in + workflow != .activate || profile.traits.needsActivationAfterReboot + } + } +} + struct MaintenanceDashboardPresentation: Equatable { let cards: [MaintenanceWorkflowCardPresentation] let detail: MaintenanceWorkflowDetailPresentation @MainActor init(store: MaintenanceStore, profile: DeviceProfile) { - self.cards = MaintenanceWorkflow.allCases.map { workflow in + let workflows = MaintenanceWorkflowAvailability.workflows(for: profile) + let selectedWorkflow = workflows.contains(store.selectedWorkflow) + ? store.selectedWorkflow + : workflows.first ?? store.selectedWorkflow + self.cards = workflows.map { workflow in let legacy = MaintenanceWorkflowPresentation.presentation(for: workflow) return MaintenanceWorkflowCardPresentation( workflow: workflow, title: legacy.title, subtitle: legacy.subtitle, stateTitle: store.state(for: workflow).title, - isSelected: workflow == store.selectedWorkflow + isSelected: workflow == selectedWorkflow ) } - self.detail = MaintenanceWorkflowDetailPresentation(store: store, profile: profile) + self.detail = MaintenanceWorkflowDetailPresentation(store: store, profile: profile, workflow: selectedWorkflow) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift index ad0f4792..556c40a5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift @@ -115,7 +115,25 @@ final class MaintenanceStore: ObservableObject { didSet { markPlansStaleForOptionChange() } } @Published var repairPath = "" { - didSet { markRepairStaleForPathChange() } + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairRecursive = true { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairMaxDepth = "" { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairIncludeHidden = false { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairIncludeTimeMachine = false { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairFixPermissions = false { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairVerbose = false { + didSet { markRepairScanStaleIfNeeded() } } @Published var selectedFsckTargetID: FsckTargetViewModel.ID? { didSet { markFsckPlanStaleIfNeeded() } @@ -147,6 +165,7 @@ final class MaintenanceStore: ObservableObject { private var plannedFsckOptions: MaintenanceOptions? private var plannedFsckTargetID: FsckTargetViewModel.ID? private var scannedRepairPath: String? + private var scannedRepairOptions: RepairXattrsOptions? private var activeOperation: ActiveOperation? private var lastProcessedEventCount = 0 private var cancellables: Set = [] @@ -236,6 +255,13 @@ final class MaintenanceStore: ObservableObject { && repairState == .scanReady && repairScan?.repairableCount ?? 0 > 0 && scannedRepairPath == trimmedRepairPath + && scannedRepairOptions == currentRepairOptions + } + + var canScanRepairXattrs: Bool { + !isBusy + && !trimmedRepairPath.isEmpty + && currentRepairOptions != nil } @discardableResult @@ -450,6 +476,10 @@ final class MaintenanceStore: ObservableObject { @discardableResult func scanRepairXattrs() -> OperationStartResult { + guard let options = currentRepairOptions else { + failLocally(workflow: .repairXattrs, message: "Max depth must be empty or a non-negative integer.") + return .rejected("Max depth must be empty or a non-negative integer.") + } guard !trimmedRepairPath.isEmpty else { failLocally(workflow: .repairXattrs, message: "Choose a mounted SMB share path before scanning.") return .rejected("Choose a mounted SMB share path before scanning.") @@ -457,7 +487,7 @@ final class MaintenanceStore: ObservableObject { let path = trimmedRepairPath let start = startRun( operation: "repair-xattrs", - params: OperationParams.repairXattrsScan(path: path), + params: OperationParams.repairXattrsScan(path: path, options: options), profile: nil, workflow: .repairXattrs ) @@ -469,6 +499,7 @@ final class MaintenanceStore: ObservableObject { repairScan = nil repairResult = nil scannedRepairPath = path + scannedRepairOptions = options return start } @@ -487,9 +518,18 @@ final class MaintenanceStore: ObservableObject { ) return .rejected("Run a fresh xattr scan before repairing.") } + guard let options = scannedRepairOptions else { + repairState = .scanStale + error = BackendErrorViewModel( + operation: "repair-xattrs", + code: "scan_stale", + message: "Run a fresh xattr scan before repairing." + ) + return .rejected("Run a fresh xattr scan before repairing.") + } let start = startRun( operation: "repair-xattrs", - params: OperationParams.repairXattrsRun(path: trimmedRepairPath), + params: OperationParams.repairXattrsRun(path: trimmedRepairPath, options: options), profile: nil, workflow: .repairXattrs ) @@ -526,6 +566,7 @@ final class MaintenanceStore: ObservableObject { plannedFsckOptions = nil plannedFsckTargetID = nil scannedRepairPath = nil + scannedRepairOptions = nil activeOperation = nil } @@ -544,6 +585,29 @@ final class MaintenanceStore: ObservableObject { repairPath.trimmingCharacters(in: .whitespacesAndNewlines) } + private var repairMaxDepthValue: Int? { + let trimmed = repairMaxDepth.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return ValueParsers.nonNegativeInteger(trimmed) + } + + private var currentRepairOptions: RepairXattrsOptions? { + let trimmed = repairMaxDepth.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, repairMaxDepthValue == nil { + return nil + } + return RepairXattrsOptions( + recursive: repairRecursive, + maxDepth: repairMaxDepthValue, + includeHidden: repairIncludeHidden, + includeTimeMachine: repairIncludeTimeMachine, + fixPermissions: repairFixPermissions, + verbose: repairVerbose + ) + } + private func resetRunState() { backend.clear() lastProcessedEventCount = 0 @@ -727,6 +791,10 @@ final class MaintenanceStore: ObservableObject { } return } + if event.code == "confirmation_cancelled" { + applyConfirmationCancelled(operation: event.operation) + return + } if event.code == "auth_failed" { passwordInvalidProfileID = activeOperation?.profileID } @@ -734,6 +802,52 @@ final class MaintenanceStore: ObservableObject { failState(for: event.operation) } + private func applyConfirmationCancelled(operation: String) { + error = nil + currentStage = nil + activeOperation = nil + switch operation { + case "activate": + activateState = activationPlan == nil ? .idle : .planReady + case "uninstall": + restoreUninstallStateAfterCancellation() + case "fsck": + restoreFsckStateAfterCancellation() + case "repair-xattrs": + restoreRepairStateAfterCancellation() + default: + break + } + } + + private func restoreUninstallStateAfterCancellation() { + guard uninstallPlan != nil else { + uninstallState = .idle + return + } + uninstallState = currentOptions == plannedUninstallOptions ? .planReady : .planStale + } + + private func restoreFsckStateAfterCancellation() { + guard fsckPlan != nil else { + fsckState = fsckTargets.isEmpty ? .idle : .listReady + return + } + fsckState = currentOptions == plannedFsckOptions && selectedFsckTargetID == plannedFsckTargetID + ? .planReady + : .planStale + } + + private func restoreRepairStateAfterCancellation() { + guard repairScan != nil else { + repairState = .idle + return + } + repairState = scannedRepairPath == trimmedRepairPath && scannedRepairOptions == currentRepairOptions + ? .scanReady + : .scanStale + } + private func applyFalseResult(_ event: BackendEvent) { error = BackendErrorViewModel( operation: event.operation, @@ -833,8 +947,9 @@ final class MaintenanceStore: ObservableObject { } } - private func markRepairStaleForPathChange() { - if repairState == .scanReady, scannedRepairPath != trimmedRepairPath { + private func markRepairScanStaleIfNeeded() { + if repairState == .scanReady, + scannedRepairPath != trimmedRepairPath || scannedRepairOptions != currentRepairOptions { repairState = .scanStale } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift index b1abd56f..0862332b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift @@ -145,7 +145,7 @@ final class OperationLane: ObservableObject { } func cancelPendingConfirmation() { - backend.pendingConfirmation = nil + backend.cancelPendingConfirmation() onStateChanged?() } @@ -232,6 +232,10 @@ final class OperationCoordinator: ObservableObject { primaryLane()?.canCancel ?? false } + var hasActiveWork: Bool { + allLanes.contains { $0.isBusy } + } + func activeOperation(for key: OperationLaneKey) -> ActiveOperation? { lane(for: key).activeOperation } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift index 75de9a6d..e1a6f69a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift @@ -28,7 +28,7 @@ enum OperationTimelineBuilder { operation: event.operation, title: title(for: event.operation, stage: event.stage), detail: event.description, - state: .running, + state: stageState(forEventAt: index, in: events), risk: event.risk, cancellable: event.cancellable ) @@ -60,6 +60,15 @@ enum OperationTimelineBuilder { } } + private static func stageState(forEventAt index: Int, in events: [BackendEvent]) -> OperationTimelineItem.State { + let event = events[index] + let laterEvents = events.dropFirst(index + 1).filter { $0.operation == event.operation } + if laterEvents.contains(where: { $0.type == "stage" || ($0.type == "result" && $0.ok == true) }) { + return .succeeded + } + return .running + } + static func operationTitle(_ operation: String) -> String { switch operation { case "discover": diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift index 1f96ce47..36c20107 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift @@ -4,6 +4,8 @@ import TimeCapsuleSMBApp @main struct TimeCapsuleSMBExecutable: App { + @NSApplicationDelegateAdaptor(AppCloseGuardApplicationDelegate.self) private var appCloseGuardDelegate + init() { NSApplication.shared.setActivationPolicy(.regular) DispatchQueue.main.async { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift index 212870e2..55a054e9 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -290,7 +290,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { 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"]) + XCTAssertEqual(fixture.runner.calls[0].params["debug_logging"], .bool(false)) } func testConfigureRejectedWhileAnotherOperationRunsSavesNothing() async throws { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift new file mode 100644 index 00000000..3b9577cf --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift @@ -0,0 +1,82 @@ +import AppKit +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AppCloseGuardTests: XCTestCase { + func testCloseGuardAllowsWindowCloseWithoutPromptWhenNoOperationIsActive() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { false } + guardController.presenter = presenter + let window = NSWindow() + + XCTAssertTrue(guardController.shouldCloseWindow(window)) + XCTAssertTrue(presenter.requests.isEmpty) + } + + func testCloseGuardRequiresSharedConfirmationWhenOperationIsActive() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { true } + guardController.presenter = presenter + let window = NSWindow() + + XCTAssertFalse(guardController.shouldCloseWindow(window)) + XCTAssertEqual(presenter.requests, [.windowClose]) + XCTAssertEqual(presenter.prompts, [.activeOperation]) + XCTAssertEqual(presenter.windows, [window]) + + let delegate = AppCloseGuardApplicationDelegate() + delegate.closeGuard = guardController + + XCTAssertEqual(delegate.applicationShouldTerminate(.shared), .terminateLater) + XCTAssertEqual(presenter.requests, [.windowClose, .appQuit]) + XCTAssertEqual(presenter.prompts, [.activeOperation, .activeOperation]) + } + + func testApplicationDelegateRoutesCommandQuitThroughCloseGuard() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { true } + guardController.presenter = presenter + let delegate = AppCloseGuardApplicationDelegate() + delegate.closeGuard = guardController + + XCTAssertEqual(delegate.applicationShouldTerminate(.shared), .terminateLater) + XCTAssertEqual(presenter.requests, [.appQuit]) + XCTAssertEqual(presenter.prompts, [.activeOperation]) + } + + func testApplicationDelegateAllowsCommandQuitWithoutPromptWhenNoOperationIsActive() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { false } + guardController.presenter = presenter + let delegate = AppCloseGuardApplicationDelegate() + delegate.closeGuard = guardController + + XCTAssertEqual(delegate.applicationShouldTerminate(.shared), .terminateNow) + XCTAssertTrue(presenter.requests.isEmpty) + } +} + +@MainActor +private final class RecordingCloseGuardPresenter: AppCloseGuardPresenting { + private(set) var prompts: [AppCloseGuardPrompt] = [] + private(set) var requests: [AppCloseGuardRequest] = [] + private(set) var windows: [NSWindow?] = [] + private(set) var completions: [@MainActor (Bool) -> Void] = [] + + func confirmClose( + _ prompt: AppCloseGuardPrompt, + for request: AppCloseGuardRequest, + modalFor window: NSWindow?, + completion: @escaping @MainActor (Bool) -> Void + ) { + prompts.append(prompt) + requests.append(request) + windows.append(window) + completions.append(completion) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift index 80274324..435ae1d0 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -128,6 +128,35 @@ final class BackendClientTests: XCTestCase { XCTAssertEqual(client.pendingConfirmation?.params["dry_run"], .bool(false)) } + func testCancelPendingConfirmationClearsPendingStateAndPublishesCancellationEvent() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deploy.", + details: .object(["confirmation_id": .string("confirm-1")]) + ) + ], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + let client = BackendClient(runner: runner) + + client.run(operation: "deploy", params: ["dry_run": .bool(false)]) + try await waitUntil { + client.pendingConfirmation != nil && !client.isRunning + } + + client.cancelPendingConfirmation() + + XCTAssertNil(client.pendingConfirmation) + XCTAssertEqual(client.events.last?.type, "error") + XCTAssertEqual(client.events.last?.operation, "deploy") + XCTAssertEqual(client.events.last?.code, "confirmation_cancelled") + XCTAssertEqual(client.events.last?.message, "Operation cancelled.") + } + func testProfileContextInjectsConfigAndPreservesExplicitConfig() async throws { let runner = RecordingHelperRunner( events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift index 1640c260..070b05b0 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -18,6 +18,38 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(presentation.domains.first?.status, .warning) } + func testInstallActionsUseDownloadBoxIconExceptReinstall() { + XCTAssertEqual(DashboardPrimaryAction.installSMB.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(DashboardSecondaryAction.installUpdate.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(CheckupUserAction.installUpdate.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(InstallUserAction.installUpdate.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(InstallUserAction.reinstall.systemImage, "arrow.clockwise") + } + + func testInstallActionAvailabilityBlocksMutatingActionsWhileDeviceIsBusy() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [])) + ], delayNanoseconds: 100_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let laneKey = OperationLaneKey.device("device-one") + let store = DeployWorkflowStore(coordinator: coordinator, laneKey: laneKey) + + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.reinstall, store: store)) + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.runCheckup, store: store)) + + _ = coordinator.run(operation: "doctor", context: nil, activeDeviceID: "device-one", laneKey: laneKey) + try await waitUntilStoreState { store.isBusy } + + XCTAssertFalse(InstallActionAvailabilityPolicy.isEnabled(.createPlan, store: store)) + XCTAssertFalse(InstallActionAvailabilityPolicy.isEnabled(.reinstall, store: store)) + XCTAssertFalse(InstallActionAvailabilityPolicy.isEnabled(.runCheckup, store: store)) + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.openFinder, store: store)) + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.viewCheckup, store: store)) + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.viewDiagnostics, store: store)) + } + func testDoctorDomainPolicyUsesTypedDetailsDomainAndSeverity() throws { let payload = try testDoctorPayload(checks: [ testDoctorCheck(status: "PASS", message: "ssh ok", domain: "Device"), @@ -199,6 +231,72 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(try row(.checkup, in: hostWarning).detail, "Time Machine warning.") } + func testOverviewActionsUseFinderLabelAndSuppressRunCheckupWhileChecking() throws { + var profile = try makeProfile() + profile.lastDeploy = DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 120), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "installed" + ) + + let healthy = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + )) + XCTAssertEqual(DashboardPrimaryAction.openSMB.title, "Open Finder") + XCTAssertEqual(healthy.primaryAction, .openSMB) + XCTAssertEqual(healthy.secondaryActions, [.runCheckup, .replacePassword, .settings]) + + let checking = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .checking, + primaryAction: .viewCheckup, + hostWarning: nil + )) + XCTAssertEqual(checking.primaryAction, .viewCheckup) + XCTAssertEqual(checking.secondaryActions, [.openFinder, .replacePassword, .settings]) + XCTAssertFalse(checking.secondaryActions.contains(.runCheckup)) + XCTAssertEqual(try row(.checkup, in: checking).action, .viewCheckup) + + let warning = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .warning, + primaryAction: .viewCheckup, + hostWarning: nil + )) + XCTAssertEqual(warning.secondaryActions, [.runCheckup, .openFinder, .replacePassword, .settings]) + } + + func testOverviewDisablesMutatingActionsWhileOperationIsActive() throws { + let profile = try makeProfile() + let installing = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .installing, + primaryAction: .installSMB, + hostWarning: nil + )) + + XCTAssertFalse(installing.isPrimaryActionEnabled) + XCTAssertEqual(installing.secondaryActions, [.runCheckup, .replacePassword, .settings]) + XCTAssertFalse(installing.isEnabled(.runCheckup)) + XCTAssertFalse(installing.isEnabled(.installUpdate)) + XCTAssertTrue(installing.isEnabled(.replacePassword)) + XCTAssertTrue(installing.isEnabled(.settings)) + + let checkup = try row(.checkup, in: installing) + XCTAssertEqual(checkup.action, .runCheckup) + XCTAssertFalse(installing.isEnabled(try XCTUnwrap(checkup.action))) + } + func testInstallPlanPresentationShowsDeviceImpactAndWarnings() throws { let plan = try netbsd4DeployPlan().decode(DeployPlanPayload.self) let profile = try makeProfile(payloadFamily: "netbsd4_samba4") @@ -248,6 +346,103 @@ final class DashboardPresentationTests: XCTestCase { } } + func testInstallWorkflowPresentationRestoresPostInstallViewFromSavedDeploySnapshot() throws { + var profile = try makeProfile() + profile.lastDeploy = DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 200), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: false, + verified: true, + summary: "Installed from previous app session." + ) + + let presentation = InstallWorkflowPresentation( + state: .idle, + plan: nil, + result: nil, + error: nil, + events: [], + currentStage: nil, + profile: profile + ) + + let completion = try XCTUnwrap(presentation.completion) + XCTAssertEqual(presentation.stateTitle, "Deployed") + XCTAssertEqual(presentation.statusMessage, "Install / Update completed.") + XCTAssertNil(presentation.primaryAction) + XCTAssertEqual(completion.title, "Install / Update Verified") + XCTAssertTrue(completion.rows.contains(PresentationRow(label: "Reboot Requested", value: "no"))) + XCTAssertTrue(completion.rows.contains(PresentationRow(label: "Message", value: "Installed from previous app session."))) + XCTAssertEqual(completion.actions, [.reinstall, .openFinder, .runCheckup, .viewDiagnostics]) + } + + func testInstallWorkflowPresentationShowsViewCheckupWhenCheckupIsRunning() throws { + var profile = try makeProfile() + profile.lastDeploy = DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 200), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: false, + verified: true, + summary: "Installed from previous app session." + ) + + let presentation = InstallWorkflowPresentation( + state: .idle, + plan: nil, + result: nil, + error: nil, + events: [], + currentStage: nil, + profile: profile, + isCheckupRunning: true + ) + + XCTAssertEqual(presentation.completion?.actions, [.reinstall, .openFinder, .viewCheckup, .viewDiagnostics]) + XCTAssertFalse(presentation.completion?.actions.contains(.runCheckup) == true) + } + + func testInstallWorkflowPresentationPrefersCurrentWorkflowOverSavedDeploySnapshot() throws { + var profile = try makeProfile() + profile.lastDeploy = DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 200), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "Installed from previous app session." + ) + let plan = try testDeployPlanPayload().decode(DeployPlanPayload.self) + let error = BackendErrorViewModel(operation: "deploy", code: "operation_failed", message: "failed") + + let planReady = InstallWorkflowPresentation( + state: .planReady, + plan: plan, + result: nil, + error: nil, + events: [], + currentStage: nil, + profile: profile + ) + XCTAssertEqual(planReady.stateTitle, "Plan Ready") + XCTAssertEqual(planReady.primaryAction, .installUpdate) + XCTAssertNil(planReady.completion) + + let deployFailed = InstallWorkflowPresentation( + state: .deployFailed, + plan: plan, + result: nil, + error: error, + events: [], + currentStage: nil, + profile: profile + ) + XCTAssertEqual(deployFailed.stateTitle, "Deploy Failed") + XCTAssertEqual(deployFailed.primaryAction, .regeneratePlan) + XCTAssertNil(deployFailed.completion) + } + func testInstallCompletionPresentationShowsVerificationAndNextActions() throws { let result = try testDeployResultPayload(payloadFamily: "netbsd4_samba4", verified: true, netbsd4: true) .decode(DeployResultPayload.self) @@ -259,7 +454,21 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(presentation.warnings, [ "NetBSD4 devices may need Activate after a later reboot unless the boot hook is patched." ]) - XCTAssertEqual(presentation.actions, [.openFinder, .runCheckup, .viewDiagnostics]) + XCTAssertEqual(presentation.actions, [.reinstall, .openFinder, .runCheckup, .viewDiagnostics]) + XCTAssertEqual(InstallUserAction.installUpdate.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(InstallUserAction.reinstall.systemImage, "arrow.clockwise") + XCTAssertEqual(InstallUserAction.reinstall.title, "Reinstall") + } + + func testInstallCompletionPresentationReplacesRunCheckupWithViewCheckupWhileChecking() throws { + let result = try testDeployResultPayload(payloadFamily: "netbsd6_samba4", verified: true, netbsd4: false) + .decode(DeployResultPayload.self) + + let presentation = InstallCompletionPresentation(result: result, isCheckupRunning: true) + + XCTAssertEqual(presentation.actions, [.reinstall, .openFinder, .viewCheckup, .viewDiagnostics]) + XCTAssertEqual(InstallUserAction.viewCheckup.title, "View Checkup") + XCTAssertEqual(InstallUserAction.viewCheckup.systemImage, "list.bullet.clipboard") } func testInstallTimelinePresentationUsesDeployEventsOnly() { @@ -283,7 +492,7 @@ final class DashboardPresentationTests: XCTestCase { let deploying = InstallProgressPresentation(state: .deploying, currentStage: stage) XCTAssertEqual(deploying?.title, "Installing / Updating SMB") - XCTAssertEqual(deploying?.message, "Uploading and applying the managed SMB runtime. This can take a few seconds.") + XCTAssertEqual(deploying?.message, "Uploading and applying the managed SMB runtime. This can take a few minutes...") XCTAssertEqual(deploying?.detail, "Uploading files.") for state in DeployWorkflowState.allCases where state != .deploying { XCTAssertNil(InstallProgressPresentation(state: state, currentStage: stage), "\(state) should not show a blocking progress modal.") @@ -364,6 +573,29 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(MaintenanceOperationState.scanReady.maintenanceStatusMessage(for: .activate), "Scan Ready") } + func testMaintenancePresentationHidesActivationForDevicesThatDoNotNeedIt() throws { + let store = MaintenanceStore(backend: BackendClient(runner: StoreTestRunner(responses: []))) + let profile = try makeProfile(payloadFamily: "netbsd6_samba4") + + let presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + + XCTAssertEqual(presentation.cards.map { $0.workflow }, [MaintenanceWorkflow.uninstall, .fsck, .repairXattrs]) + XCTAssertEqual(presentation.cards.first?.isSelected, true) + XCTAssertEqual(presentation.detail.workflow, .uninstall) + XCTAssertEqual(presentation.detail.title, "Uninstall") + } + + func testMaintenancePresentationKeepsActivationForNetBSD4Devices() throws { + let store = MaintenanceStore(backend: BackendClient(runner: StoreTestRunner(responses: []))) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + + let presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + + XCTAssertEqual(presentation.cards.map { $0.workflow }, [MaintenanceWorkflow.activate, .uninstall, .fsck, .repairXattrs]) + XCTAssertEqual(presentation.cards.first?.isSelected, true) + XCTAssertEqual(presentation.detail.workflow, .activate) + } + func testMaintenancePresentationBuildsWorkflowPlansAndCompletions() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ @@ -386,7 +618,7 @@ final class DashboardPresentationTests: XCTestCase { ]) ]) let store = MaintenanceStore(backend: BackendClient(runner: runner)) - let profile = try makeProfile() + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") store.planActivation(password: "pw") try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index df890c6f..0d7a7fcc 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -211,12 +211,20 @@ final class DashboardStoreTests: XCTestCase { passwordState: .available, preferredID: "device-one" ) - profile.settings = DeviceProfileSettings(nbnsEnabled: false, debugLogging: true, mountWaitSeconds: 45) + profile.settings = DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 45 + ) profile = try await fixture.registry.updateProfile(profile) let dashboard = DashboardStore(appStore: fixture.appStore) let session = dashboard.session(for: profile) XCTAssertEqual(session.deployStore.nbnsEnabled, false) + XCTAssertEqual(session.deployStore.internalShareUseDiskRoot, true) + XCTAssertEqual(session.deployStore.anyProtocol, true) XCTAssertEqual(session.deployStore.debugLogging, true) XCTAssertEqual(session.deployStore.mountWait, "45") XCTAssertEqual(session.maintenanceStore.mountWait, "45") @@ -246,6 +254,8 @@ final class DashboardStoreTests: XCTestCase { let session = dashboard.session(for: profile) session.profileEditorStore.draft.nbnsEnabled = false + session.profileEditorStore.draft.internalShareUseDiskRoot = true + session.profileEditorStore.draft.anyProtocol = true session.profileEditorStore.draft.debugLogging = true session.profileEditorStore.draft.mountWaitSeconds = "64" @@ -253,6 +263,8 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(session.profileEditorStore.state, .saved) XCTAssertEqual(session.deployStore.nbnsEnabled, false) + XCTAssertEqual(session.deployStore.internalShareUseDiskRoot, true) + XCTAssertEqual(session.deployStore.anyProtocol, true) XCTAssertEqual(session.deployStore.debugLogging, true) XCTAssertEqual(session.deployStore.mountWait, "64") XCTAssertEqual(session.maintenanceStore.mountWait, "64") @@ -392,6 +404,47 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls[2].context?.profileID, profile.id) } + func testSuccessfulUninstallClearsInstalledSnapshot() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallResultPayload(waited: true, verified: true)) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + await fixture.registry.updateDeploy(DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 120), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "installed" + ), for: profile.id) + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + try fixture.passwordStore.save("pw", for: installed.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: installed) + + session.performMaintenanceAction(.planUninstall, profile: installed) {} + try await waitUntilStoreState { session.maintenanceStore.uninstallState == .planReady } + session.performMaintenanceAction(.runUninstall, profile: installed) {} + + try await waitUntilStoreState { + session.maintenanceStore.uninstallState == .succeeded + && fixture.registry.profile(id: installed.id)?.lastDeploy == nil + } + XCTAssertNil(fixture.registry.profile(id: installed.id)?.lastDeploy) + XCTAssertEqual(fixture.runner.calls[0].params["dry_run"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(false)) + } + func testCheckupSnapshotUsesStartedProfileWhenSelectionChanges() async throws { let fixture = try await makeFixture(responses: [ .init(events: [ @@ -460,6 +513,7 @@ final class DashboardStoreTests: XCTestCase { fixture.appStore.select(second) try await waitUntilStoreState { session.deployStore.state == .deployed } + try await waitUntilStoreState { fixture.registry.profile(id: first.id)?.lastDeploy?.state == .deployed } XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastDeploy?.state, .deployed) XCTAssertNil(fixture.registry.profile(id: second.id)?.lastDeploy) } @@ -599,6 +653,9 @@ final class DashboardStoreTests: XCTestCase { BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") ])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) ]) ]) let profile = try await fixture.registry.saveConfiguredDevice( @@ -679,6 +736,14 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") XCTAssertEqual(session.selectedTab, .checkup) + session.performInstallAction(.reinstall, profile: profile) { + diagnosticsShown = true + } + try await waitUntilStoreState { fixture.runner.calls.count == 2 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + XCTAssertEqual(fixture.runner.calls[1].operation, "deploy") + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(true)) + XCTAssertEqual(session.selectedTab, .install) + session.performInstallAction(.viewDiagnostics, profile: profile) { diagnosticsShown = true } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift index 505f06d8..877bc57f 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift @@ -161,7 +161,7 @@ final class DeployWorkflowStoreTests: XCTestCase { XCTAssertEqual(runner.calls[1].params["no_wait"], .bool(true)) } - func testDefaultRuntimeOverridesAreOmittedFromPlanParams() async throws { + func testDefaultRuntimeOverridesAreSentExplicitlyInPlanParams() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) @@ -172,8 +172,8 @@ final class DeployWorkflowStoreTests: XCTestCase { store.runPlan(password: "pw") try await waitUntilStoreState { store.state == .planReady } - XCTAssertNil(runner.calls[0].params["internal_share_use_disk_root"]) - XCTAssertNil(runner.calls[0].params["any_protocol"]) + XCTAssertEqual(runner.calls[0].params["internal_share_use_disk_root"], .bool(false)) + XCTAssertEqual(runner.calls[0].params["any_protocol"], .bool(false)) } func testDeploySendsRunParamsFromPlanOptionsAndStoresResult() async throws { @@ -207,6 +207,59 @@ final class DeployWorkflowStoreTests: XCTestCase { XCTAssertEqual(runner.calls[1].params["credentials"], .object(["password": .string("pw2")])) } + func testDeployCannotRunAgainDirectlyFromDeployedState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployResultPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + try await waitUntilStoreState { store.state == .deployed } + + let result = store.runDeploy(password: "pw2") + + XCTAssertEqual(result.rejectionMessage, "Deploy plan is not ready.") + XCTAssertEqual(store.state, .deployed) + XCTAssertEqual(runner.calls.count, 2) + } + + func testReinstallCreatesFreshPlanFromDeployedState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployResultPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + try await waitUntilStoreState { store.state == .deployed } + + store.noWait = true + store.runPlan(password: "pw2") + + try await waitUntilStoreState { store.state == .planReady && runner.calls.count == 3 } + XCTAssertNil(store.result) + XCTAssertEqual(runner.calls[2].operation, "deploy") + XCTAssertEqual(runner.calls[2].params["dry_run"], .bool(true)) + XCTAssertEqual(runner.calls[2].params["no_wait"], .bool(true)) + XCTAssertEqual(runner.calls[2].params["credentials"], .object(["password": .string("pw2")])) + } + func testConfirmationRequiredMovesToAwaitingConfirmationThenConfirmedDeployCompletes() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ @@ -247,6 +300,72 @@ final class DeployWorkflowStoreTests: XCTestCase { XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("confirm-1")) } + func testCancellingDeployConfirmationRestoresReadyPlan() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deployment.", + details: .object(["confirmation_id": .string("confirm-1")]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let backend = BackendClient(runner: runner) + let store = DeployWorkflowStore(backend: backend) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + try await waitUntilStoreState { store.state == .awaitingConfirmation && backend.pendingConfirmation != nil } + + backend.cancelPendingConfirmation() + + try await waitUntilStoreState { store.state == .planReady && backend.pendingConfirmation == nil } + XCTAssertNil(store.error) + XCTAssertNil(store.currentStage) + XCTAssertTrue(store.canDeploy) + XCTAssertNotNil(store.plan) + XCTAssertEqual(runner.calls.count, 2) + } + + func testCancellingDeployConfirmationRestoresStalePlanWhenOptionsChanged() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deployment.", + details: .object(["confirmation_id": .string("confirm-1")]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let backend = BackendClient(runner: runner) + let store = DeployWorkflowStore(backend: backend) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + try await waitUntilStoreState { store.state == .awaitingConfirmation && backend.pendingConfirmation != nil } + + store.noWait = true + backend.cancelPendingConfirmation() + + try await waitUntilStoreState { store.state == .planStale && backend.pendingConfirmation == nil } + XCTAssertNil(store.error) + XCTAssertFalse(store.canDeploy) + XCTAssertNotNil(store.plan) + XCTAssertEqual(runner.calls.count, 2) + } + func testDeployBackendErrorMovesToDeployFailedWithRecovery() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift index 423b1430..70dd8a9e 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift @@ -44,6 +44,49 @@ final class DeviceProfileEditorStoreTests: XCTestCase { } } + func testUndoingDraftChangeReturnsEditorToCleanState() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + XCTAssertEqual(store.state, .clean) + XCTAssertFalse(store.canSave) + + store.draft.nbnsEnabled.toggle() + + XCTAssertEqual(store.state, .dirty) + XCTAssertTrue(store.canSave) + + store.draft.nbnsEnabled.toggle() + + XCTAssertEqual(store.state, .clean) + XCTAssertFalse(store.canSave) + } + + func testCleanEditorSyncsToUpdatedProfileBaseline() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + var updatedProfile = profile + updatedProfile.displayName = "Renamed Capsule" + + store.sync(to: updatedProfile) + + XCTAssertEqual(store.draft.displayName, "Renamed Capsule") + XCTAssertEqual(store.state, .clean) + XCTAssertFalse(store.canSave) + } + func testUnchangedHostSaveUpdatesProfileSettingsWithoutBackendConfigure() async throws { let fixture = try await makeFixture(responses: []) let profile = try await fixture.registry.saveConfiguredDevice( @@ -56,6 +99,8 @@ final class DeviceProfileEditorStoreTests: XCTestCase { store.draft.displayName = "Media Capsule" store.draft.nbnsEnabled = false + store.draft.internalShareUseDiskRoot = true + store.draft.anyProtocol = true store.draft.debugLogging = true store.draft.mountWaitSeconds = "45" @@ -65,7 +110,13 @@ final class DeviceProfileEditorStoreTests: XCTestCase { XCTAssertEqual(store.state, .saved) XCTAssertEqual(saved.displayName, "Media Capsule") XCTAssertEqual(saved.host, "root@10.0.0.2") - XCTAssertEqual(saved.settings, DeviceProfileSettings(nbnsEnabled: false, debugLogging: true, mountWaitSeconds: 45)) + XCTAssertEqual(saved.settings, DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 45 + )) XCTAssertEqual(fixture.runner.calls, []) } @@ -182,6 +233,8 @@ final class DeviceProfileEditorStoreTests: XCTestCase { store.draft.displayName = "Updated Capsule" store.draft.host = "10.0.0.9" store.draft.nbnsEnabled = false + store.draft.internalShareUseDiskRoot = true + store.draft.anyProtocol = true store.draft.debugLogging = true store.draft.mountWaitSeconds = "60" @@ -195,6 +248,9 @@ final class DeviceProfileEditorStoreTests: XCTestCase { XCTAssertEqual(call.params["host"], .string("root@10.0.0.9")) XCTAssertEqual(call.params["password"], .string("pw")) XCTAssertEqual(call.params["persist_password"], .bool(false)) + XCTAssertEqual(call.params["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(call.params["any_protocol"], .bool(true)) + XCTAssertEqual(call.params["debug_logging"], .bool(true)) let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) XCTAssertEqual(saved.id, profile.id) @@ -203,7 +259,13 @@ final class DeviceProfileEditorStoreTests: XCTestCase { XCTAssertEqual(saved.host, "root@10.0.0.9") XCTAssertEqual(saved.lastCheckup?.state, .passed) XCTAssertEqual(saved.lastDeploy?.state, .deployed) - XCTAssertEqual(saved.settings, DeviceProfileSettings(nbnsEnabled: false, debugLogging: true, mountWaitSeconds: 60)) + XCTAssertEqual(saved.settings, DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 60 + )) } func testAuthFailureAndUnsupportedDeviceSaveNothing() async throws { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift index d7806443..7a607886 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift @@ -55,6 +55,24 @@ final class DeviceProfileTests: XCTestCase { XCTAssertEqual(profile.runtimeContext.configURL.path, "/tmp/devices/abc/.env") } + func testProfileSettingsDecodeMissingNewKeysWithDefaults() throws { + let data = Data(""" + { + "nbnsEnabled": false, + "debugLogging": true, + "mountWaitSeconds": 45 + } + """.utf8) + + let settings = try JSONDecoder().decode(DeviceProfileSettings.self, from: data) + + XCTAssertEqual(settings.nbnsEnabled, false) + XCTAssertEqual(settings.internalShareUseDiskRoot, false) + XCTAssertEqual(settings.anyProtocol, false) + XCTAssertEqual(settings.debugLogging, true) + XCTAssertEqual(settings.mountWaitSeconds, 45) + } + func testTraitsClassifyNetBSD4NetBSD6AndUnsupportedDevices() { let netbsd4 = makeProfile(payloadFamily: "netbsd4_samba4") XCTAssertTrue(netbsd4.traits.isNetBSD4) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift index 1eb9408c..384bcdf2 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift @@ -42,6 +42,10 @@ final class DeviceStatusPolicyTests: XCTestCase { ]) } + func testInstallingStatusUsesInstallIcon() { + XCTAssertEqual(DeviceDisplayStatus.installing.systemImage, "square.and.arrow.down.on.square") + } + func testPasswordStateTitlesAreLocalized() { XCTAssertEqual(DevicePasswordState.allCases.map(\.title), [ "Unknown", @@ -58,7 +62,7 @@ final class DeviceStatusPolicyTests: XCTestCase { "Install / Update", "Checkup", "Maintenance", - "Advanced" + "Settings" ]) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift index 380696c2..8864ea86 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift @@ -53,6 +53,14 @@ final class FlashWorkflowStoreTests: XCTestCase { XCTAssertFalse(eligibility.writeAllowed) } + func testBootHookSectionVisibilityIsLimitedToNetBSD4Profiles() throws { + let netbsd4 = try makeProfile(payloadFamily: "netbsd4_samba4") + let netbsd6 = try makeProfile(payloadFamily: "netbsd6_samba4") + + XCTAssertTrue(FlashBootHookVisibilityPolicy.isVisible(for: netbsd4)) + XCTAssertFalse(FlashBootHookVisibilityPolicy.isVisible(for: netbsd6)) + } + func testFlashPresentationExposesAllActionsButEnablesOnlyReadOnlyEntryPoint() { let readOnlyStates: Set = [ .eligibleForReadOnlyAnalysis, diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift index 6b174fd6..0ff8c3a1 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift @@ -103,6 +103,105 @@ final class MaintenanceStoreTests: XCTestCase { XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("activate-confirm")) } + func testConfirmationCancellationRestoresMaintenanceWorkflowState() async throws { + do { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "activate", id: "activate-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + + 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.cancelPendingConfirmation() + + try await waitUntilStoreState { store.activateState == .planReady && backend.pendingConfirmation == nil } + XCTAssertNil(store.error) + } + + do { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "uninstall", id: "uninstall-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let 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 } + store.noWait = true + backend.cancelPendingConfirmation() + + try await waitUntilStoreState { store.uninstallState == .planStale && backend.pendingConfirmation == nil } + XCTAssertNil(store.error) + } + + do { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [testFsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "fsck", id: "fsck-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + + store.refreshFsckTargets(password: "pw") + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + store.planFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + store.runFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .awaitingConfirmation && backend.pendingConfirmation != nil } + store.noWait = true + backend.cancelPendingConfirmation() + + try await waitUntilStoreState { store.fsckState == .planStale && backend.pendingConfirmation == nil } + XCTAssertNil(store.error) + } + + do { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]), + .init(events: [ + confirmationRequired(operation: "repair-xattrs", id: "repair-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + store.repairPath = "/Volumes/Data" + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + store.runRepairXattrs() + try await waitUntilStoreState { store.repairState == .awaitingConfirmation && backend.pendingConfirmation != nil } + store.repairPath = "/Volumes/Other" + backend.cancelPendingConfirmation() + + try await waitUntilStoreState { store.repairState == .scanStale && backend.pendingConfirmation == nil } + XCTAssertNil(store.error) + } + } + func testActivationBackendErrorAndMalformedPayloadFail() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ @@ -395,6 +494,12 @@ final class MaintenanceStoreTests: XCTestCase { let backend = BackendClient(runner: runner) let store = MaintenanceStore(backend: backend) store.repairPath = "/Volumes/Data" + store.repairRecursive = false + store.repairMaxDepth = "2" + store.repairIncludeHidden = true + store.repairIncludeTimeMachine = true + store.repairFixPermissions = true + store.repairVerbose = true store.scanRepairXattrs() @@ -402,6 +507,12 @@ final class MaintenanceStoreTests: XCTestCase { XCTAssertEqual(store.currentStage?.stage, "scan_findings") XCTAssertTrue(store.canRepairXattrs) XCTAssertEqual(runner.calls[0].params["dry_run"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["recursive"], .bool(false)) + XCTAssertEqual(runner.calls[0].params["max_depth"], .number(2)) + XCTAssertEqual(runner.calls[0].params["include_hidden"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["include_time_machine"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["fix_permissions"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["verbose"], .bool(true)) store.repairPath = "/Volumes/Other" XCTAssertEqual(store.repairState, .scanStale) @@ -419,6 +530,8 @@ final class MaintenanceStoreTests: XCTestCase { try await waitUntilStoreState { store.repairState == .repaired } XCTAssertEqual(store.repairResult?.repairableCount, 0) XCTAssertEqual(runner.calls[3].params["confirmation_id"], .string("repair-confirm")) + XCTAssertEqual(runner.calls[3].params["recursive"], .bool(false)) + XCTAssertEqual(runner.calls[3].params["max_depth"], .number(2)) store.scanRepairXattrs() try await waitUntilStoreState { store.repairState == .failed } @@ -426,6 +539,36 @@ final class MaintenanceStoreTests: XCTestCase { XCTAssertEqual(store.error?.recovery?.title, "repair-xattrs cannot run") } + func testRepairXattrsOptionChangesInvalidateScan() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + store.repairPath = "/Volumes/Data" + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + XCTAssertTrue(store.canRepairXattrs) + XCTAssertEqual(runner.calls[0].params["recursive"], .bool(true)) + XCTAssertNil(runner.calls[0].params["max_depth"]) + + store.repairMaxDepth = "3" + XCTAssertEqual(store.repairState, .scanStale) + XCTAssertFalse(store.canRepairXattrs) + store.runRepairXattrs() + XCTAssertEqual(store.error?.code, "scan_stale") + XCTAssertEqual(runner.calls.count, 1) + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + XCTAssertEqual(runner.calls[1].params["max_depth"], .number(3)) + } + func testRepairXattrsMissingPathZeroRepairableAndMalformedPayload() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ @@ -440,8 +583,16 @@ final class MaintenanceStoreTests: XCTestCase { store.scanRepairXattrs() XCTAssertEqual(store.repairState, .failed) XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertFalse(store.canScanRepairXattrs) store.repairPath = "/Volumes/Data" + store.repairMaxDepth = "-1" + store.scanRepairXattrs() + XCTAssertEqual(store.repairState, .failed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + + store.repairMaxDepth = "" store.scanRepairXattrs() try await waitUntilStoreState { store.repairState == .scanReady } XCTAssertFalse(store.canRepairXattrs) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift index db01814d..59555ce7 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift @@ -108,7 +108,7 @@ final class OperationCoordinatorLaneTests: XCTestCase { let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) XCTAssertStarted(coordinator.run(operation: "discover", laneKey: .app)) - try await waitUntilStoreState { coordinator.appLane.backend.isRunning } + try await waitUntilStoreState { coordinator.appLane.backend.isRunning && runner.calls.count == 1 } let second = coordinator.run(operation: "capabilities", laneKey: .app) XCTAssertEqual(second.rejectionMessage, "Another operation is already running.") @@ -170,6 +170,71 @@ final class OperationCoordinatorLaneTests: XCTestCase { XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("deploy-confirm")) } + func testCancelPendingConfirmationClearsTargetLaneAndPublishesCancellationEvent() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + confirmationRequiredEvent(operation: "deploy", id: "deploy-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let laneKey = OperationLaneKey.device("device-one") + + XCTAssertStarted(coordinator.run( + operation: "deploy", + params: ["dry_run": .bool(false)], + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: laneKey + )) + try await waitUntilStoreState { + coordinator.lane(for: laneKey).backend.pendingConfirmation != nil + && !coordinator.lane(for: laneKey).backend.isRunning + } + + coordinator.cancelPendingConfirmation() + + try await waitUntilStoreState { + coordinator.pendingConfirmation == nil && coordinator.activeOperations[laneKey] == nil + } + let events = coordinator.lane(for: laneKey).backend.events + XCTAssertEqual(events.last?.code, "confirmation_cancelled") + XCTAssertEqual(runner.calls.count, 1) + } + + func testHasActiveWorkTracksRunningAndPendingConfirmation() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + confirmationRequiredEvent(operation: "deploy", id: "deploy-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: ""), delayNanoseconds: 100_000_000) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let laneKey = OperationLaneKey.device("device-one") + + XCTAssertFalse(coordinator.hasActiveWork) + XCTAssertStarted(coordinator.run( + operation: "deploy", + params: ["dry_run": JSONValue.bool(false)], + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: laneKey + )) + XCTAssertTrue(coordinator.hasActiveWork) + + try await waitUntilStoreState { + coordinator.lane(for: laneKey).backend.pendingConfirmation != nil + && !coordinator.lane(for: laneKey).backend.isRunning + } + XCTAssertTrue(coordinator.hasActiveWork) + + coordinator.cancelPendingConfirmation() + + try await waitUntilStoreState { !coordinator.hasActiveWork } + } + func testCancelOnlyCancelsTargetLane() async throws { let runner = OperationKeyedStoreTestRunner(responses: [ .init("doctor", profileID: "device-one"): [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift index 76fbe63f..cb5c7b58 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift @@ -31,10 +31,44 @@ final class OperationTimelineBuilderTests: XCTestCase { XCTAssertEqual(timeline.map(\.title), ["Uploading", "Needs Confirmation", "Done"]) XCTAssertEqual(timeline[0].risk, "remote_write") XCTAssertEqual(timeline[0].cancellable, false) + XCTAssertEqual(timeline[0].state, .succeeded) XCTAssertEqual(timeline[1].state, .warning) + XCTAssertEqual(timeline[2].state, .succeeded) XCTAssertEqual(timeline[2].detail, "deployment completed.") } + func testStageBecomesSucceededWhenLaterStageForSameOperationAppears() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "validate_artifacts"), + BackendEvent(type: "stage", operation: "doctor", stage: "run_checks"), + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload") + ]) + + XCTAssertEqual(timeline.map(\.title), ["Checking Bundled Files", "Running Checkup", "Uploading"]) + XCTAssertEqual(timeline.map(\.state), [.succeeded, .running, .running]) + } + + func testSuccessfulResultCompletesLastStage() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "uninstall", stage: "build_uninstall_plan"), + BackendEvent(type: "stage", operation: "uninstall", stage: "uninstall_payload"), + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: .object(["summary": .string("removed")])) + ]) + + XCTAssertEqual(timeline.map(\.title), ["Planning Uninstall", "Removing Managed Files", "Done"]) + XCTAssertEqual(timeline.map(\.state), [.succeeded, .succeeded, .succeeded]) + } + + func testFailureDoesNotMarkCurrentStageSucceeded() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload"), + BackendEvent(type: "result", operation: "deploy", ok: false, payload: .object(["summary": .string("upload failed")])) + ]) + + XCTAssertEqual(timeline.map(\.title), ["Uploading", "Failed"]) + XCTAssertEqual(timeline.map(\.state), [.running, .failed]) + } + func testOperationTitlesAreUserFacing() { XCTAssertEqual(OperationTimelineBuilder.operationTitle("deploy"), "Install / Update") XCTAssertEqual(OperationTimelineBuilder.operationTitle("doctor"), "Checkup") diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index e649a36e..cefd289c 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -7,6 +7,7 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(L10n.string("button.uninstall_plan"), "Uninstall Plan") XCTAssertEqual(L10n.string("button.capabilities"), "Capabilities") XCTAssertEqual(L10n.string("helper.error.cancelled"), "Operation cancelled.") + XCTAssertEqual(L10n.string("confirm.backend.message"), "Continue with this operation?") XCTAssertEqual(L10n.format("event.summary.result", "deploy", "finished"), "deploy: finished") } @@ -39,8 +40,8 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(params["debug_logging"], .bool(true)) XCTAssertEqual(params["mount_wait"], .number(45)) XCTAssertEqual(params["no_wait"], .bool(true)) - XCTAssertNil(params["internal_share_use_disk_root"]) - XCTAssertNil(params["any_protocol"]) + XCTAssertEqual(params["internal_share_use_disk_root"], .bool(false)) + XCTAssertEqual(params["any_protocol"], .bool(false)) XCTAssertNil(params["credentials"]) } @@ -75,13 +76,17 @@ final class PendingConfirmationTests: XCTestCase { host: "root@manual", selectedRecord: selectedRecord, password: "pw", - debugLogging: true + debugLogging: true, + internalShareUseDiskRoot: false, + anyProtocol: true ) XCTAssertNil(params["host"]) XCTAssertEqual(params["selected_record"], selectedRecord) XCTAssertEqual(params["password"], .string("pw")) XCTAssertEqual(params["debug_logging"], .bool(true)) + XCTAssertEqual(params["internal_share_use_disk_root"], .bool(false)) + XCTAssertEqual(params["any_protocol"], .bool(true)) } func testConfigureParamsDefaultBareManualHostToRootUser() { @@ -94,6 +99,9 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(params["host"], .string("root@10.0.0.2")) XCTAssertEqual(params["password"], .string("pw")) XCTAssertEqual(params["persist_password"], .bool(false)) + XCTAssertEqual(params["debug_logging"], .bool(false)) + XCTAssertNil(params["internal_share_use_disk_root"]) + XCTAssertNil(params["any_protocol"]) } func testPendingConfirmationBuildsFromBackendEvent() throws { @@ -124,9 +132,108 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(confirmation.params["credentials"], .object(["password": .string("pw")])) } + func testPendingConfirmationPrefersLocalizedPresentationForKnownBackendKey() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message."), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("deploy.reboot"), + "presentation_values": .object(["device_name": .string("Time Capsule")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Deploy And Reboot?") + XCTAssertEqual(confirmation.message, "Deploy TimeCapsuleSMB and reboot this Time Capsule?") + XCTAssertEqual(confirmation.actionTitle, "Deploy and Reboot") + } + + func testPendingConfirmationUsesLocalizedQuestionForUninstallWithoutReboot() throws { + let event = BackendEvent( + type: "error", + operation: "uninstall", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "message": .string("Remove managed TimeCapsuleSMB files from the device."), + "action_title": .string("Backend uninstall"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("uninstall.no_reboot"), + "presentation_values": .object(["no_reboot": .bool(true)]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Uninstall?") + XCTAssertEqual(confirmation.message, "Remove managed TimeCapsuleSMB files from the device?") + XCTAssertEqual(confirmation.actionTitle, "Uninstall") + } + + func testPendingConfirmationFormatsLocalizedPresentationValues() throws { + let event = BackendEvent( + type: "error", + operation: "repair-xattrs", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "message": .string("Repair xattrs."), + "action_title": .string("Backend repair"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("repair_xattrs"), + "presentation_values": .object(["path": .string("/Volumes/Data")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Repair Extended Attributes?") + XCTAssertEqual(confirmation.message, "Repair known-safe macOS metadata issues under /Volumes/Data?") + XCTAssertEqual(confirmation.actionTitle, "Repair xattrs") + } + + func testPendingConfirmationFallsBackToBackendTextForUnknownPresentationKey() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Backend event fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message?"), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("deploy.future") + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Backend title") + XCTAssertEqual(confirmation.message, "Backend message?") + XCTAssertEqual(confirmation.actionTitle, "Backend action") + } + func testMaintenanceRunParamsDoNotCarryFrontendConsentFlags() { let fsck = OperationParams.fsckRun(volume: "Data", noReboot: true, noWait: true, mountWait: 18, password: "") - let repair = OperationParams.repairXattrsRun(path: "/Volumes/Data") + let repair = OperationParams.repairXattrsRun( + path: "/Volumes/Data", + options: RepairXattrsOptions( + recursive: false, + maxDepth: 4, + includeHidden: true, + includeTimeMachine: true, + fixPermissions: true, + verbose: true + ) + ) XCTAssertNil(fsck["confirm_fsck"]) XCTAssertEqual(fsck["no_reboot"], .bool(true)) @@ -136,6 +243,12 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(repair["path"], .string("/Volumes/Data")) XCTAssertEqual(repair["dry_run"], .bool(false)) + XCTAssertEqual(repair["recursive"], .bool(false)) + XCTAssertEqual(repair["max_depth"], .number(4)) + XCTAssertEqual(repair["include_hidden"], .bool(true)) + XCTAssertEqual(repair["include_time_machine"], .bool(true)) + XCTAssertEqual(repair["fix_permissions"], .bool(true)) + XCTAssertEqual(repair["verbose"], .bool(true)) XCTAssertNil(repair["confirm_repair"]) } } diff --git a/macos/TimeCapsuleSMB/tools/package_app.py b/macos/TimeCapsuleSMB/tools/package_app.py index bbdcd31d..0a2ead45 100755 --- a/macos/TimeCapsuleSMB/tools/package_app.py +++ b/macos/TimeCapsuleSMB/tools/package_app.py @@ -59,7 +59,7 @@ def write_info_plist(contents_dir: Path) -> None: "CFBundlePackageType": "APPL", "CFBundleShortVersionString": "0.1.0", "CFBundleVersion": "1", - "LSMinimumSystemVersion": "13.0", + "LSMinimumSystemVersion": "14.0", "NSHighResolutionCapable": True, } with (contents_dir / "Info.plist").open("wb") as handle: diff --git a/src/timecapsulesmb/app/confirmations.py b/src/timecapsulesmb/app/confirmations.py index 26c9f22f..20c39783 100644 --- a/src/timecapsulesmb/app/confirmations.py +++ b/src/timecapsulesmb/app/confirmations.py @@ -37,6 +37,8 @@ class ConfirmationRequest: confirmation_id: str summary: str context: Mapping[str, object] + presentation_id: str + presentation_values: Mapping[str, object] def to_jsonable(self) -> dict[str, object]: return { @@ -49,6 +51,8 @@ def to_jsonable(self) -> dict[str, object]: "confirmation_id": self.confirmation_id, "summary": self.summary, "context": jsonable(dict(self.context)), + "presentation_id": self.presentation_id, + "presentation_values": jsonable(dict(self.presentation_values)), } @@ -87,6 +91,8 @@ def build_confirmation( risk: str, summary: str, context: Mapping[str, object], + presentation_id: str, + presentation_values: Mapping[str, object] | None = None, ) -> ConfirmationRequest: return ConfirmationRequest( operation=operation, @@ -97,6 +103,8 @@ def build_confirmation( confirmation_id=_confirmation_id(operation, params, context), summary=summary, context=context, + presentation_id=presentation_id, + presentation_values=presentation_values or {}, ) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index 90d4ca5f..97413f58 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -71,6 +71,7 @@ bool_param, config_path, int_param, + optional_bool_param, ) from timecapsulesmb.services.credentials import overlay_request_credentials from timecapsulesmb.services.deploy import ( @@ -131,7 +132,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes no_wait = bool_param(params, "no_wait") mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) allow_unsupported = bool_param(params, "allow_unsupported") - debug_logging = bool_param(params, "debug_logging") + debug_logging = optional_bool_param(params, "debug_logging") config, target = load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) connection = target.connection @@ -175,22 +176,32 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes ) if is_netbsd4: title = "Confirm NetBSD4 deployment" - message = f"Deploy and activate the NetBSD4 payload on this {device_name}. Remote services will be changed." + message = f"Deploy and activate the NetBSD4 payload on this {device_name} and change remote services?" action_title = "Deploy and activate" risk = "destructive" summary = "NetBSD4 deployment with service activation" + presentation_id = "deploy.netbsd4" elif no_reboot: title = "Confirm deployment" - message = f"Deploy TimeCapsuleSMB to this {device_name} without rebooting it." + message = f"Deploy TimeCapsuleSMB to this {device_name} without rebooting it?" action_title = "Deploy" risk = "remote_write" summary = "Deployment without reboot" + presentation_id = "deploy.no_reboot" else: title = "Confirm deployment and reboot" - message = f"Deploy TimeCapsuleSMB and reboot this {device_name}." + message = f"Deploy TimeCapsuleSMB and reboot this {device_name}?" action_title = "Deploy and reboot" risk = "reboot" summary = "Deployment with reboot request" + presentation_id = "deploy.reboot" + presentation_values = { + "device_name": device_name, + "netbsd4": is_netbsd4, + "requires_reboot": bool(confirmation_plan.reboot_required), + "no_reboot": no_reboot, + "no_wait": no_wait, + } require_confirmation( params, build_confirmation( @@ -209,6 +220,8 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes "no_reboot": no_reboot, "no_wait": no_wait, }, + presentation_id=presentation_id, + presentation_values=presentation_values, ), legacy_names=( ("confirm_deploy", "confirm_netbsd4_activation") diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index 5c1d6e59..7bf7ec98 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -95,7 +95,7 @@ def activate_operation(params: dict[str, object], sink: EventSink) -> OperationR operation=operation, params=params, title="Confirm NetBSD4 activation", - message="Activate the deployed NetBSD4 payload and restart managed services.", + message="Activate the deployed NetBSD4 payload and restart managed services?", action_title="Activate", risk="destructive", summary="NetBSD4 service activation", @@ -103,6 +103,8 @@ def activate_operation(params: dict[str, object], sink: EventSink) -> OperationR "host": confirmation_connection.host, "netbsd4": True, }, + presentation_id="activate.netbsd4", + presentation_values={"netbsd4": True}, ), legacy_names=("confirm_netbsd4_activation",), ) @@ -145,6 +147,12 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation sink.stage(operation, "resolve_connection") connection = resolve_env_connection(config, allow_empty_password=True) if not dry_run: + presentation_id = "uninstall.no_reboot" if no_reboot else "uninstall.reboot" + presentation_values = { + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + } require_confirmation( params, build_confirmation( @@ -153,7 +161,7 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation title="Confirm uninstall", message=( "Remove managed TimeCapsuleSMB files from the device" - + (" and reboot it." if not no_reboot else ".") + + (" and reboot it?" if not no_reboot else "?") ), action_title="Uninstall", risk="destructive" if not no_reboot else "remote_write", @@ -164,6 +172,8 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation "no_reboot": no_reboot, "no_wait": no_wait, }, + presentation_id=presentation_id, + presentation_values=presentation_values, ), legacy_names=("confirm_uninstall",) if no_reboot else ("confirm_uninstall", "confirm_reboot"), ) @@ -239,18 +249,30 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul if dry_run and list_volumes: raise AppOperationError("dry_run and list_volumes are mutually exclusive.", code="validation_failed") if not dry_run and not list_volumes: + presentation_id = "fsck.no_reboot" if no_reboot else "fsck.reboot" + volume = string_param(params, "volume") require_confirmation( params, build_confirmation( operation=operation, params=params, title="Confirm fsck", - message="Run fsck on the selected HFS volume" + (" and reboot the device." if not no_reboot else "."), + 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"), + "volume": volume, + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + presentation_id=presentation_id, + presentation_values={ + "volume": volume, "requires_reboot": not no_reboot, "no_reboot": no_reboot, "no_wait": no_wait, @@ -375,11 +397,13 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera operation=operation, params=params, title="Confirm xattr repair", - message=f"Repair known-safe macOS metadata issues under {path}.", + message=f"Repair known-safe macOS metadata issues under {path}?", action_title="Repair xattrs", risk="local_write", summary="Repair local mounted-share metadata", context={"path": str(path)}, + presentation_id="repair_xattrs", + presentation_values={"path": str(path)}, ), legacy_names=("confirm_repair",), ) diff --git a/src/timecapsulesmb/cli/deploy.py b/src/timecapsulesmb/cli/deploy.py index 86dc322e..899358ea 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -204,7 +204,7 @@ def main(argv: Optional[list[str]] = None) -> int: config, payload_home, nbns_enabled=nbns_enabled, - debug_logging=args.debug_logging, + debug_logging=True if args.debug_logging else None, ) with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index 6ed5b105..ef1edb94 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -61,6 +61,12 @@ def bool_param(params: dict[str, object], name: str, default: bool = False) -> b raise AppOperationError(f"{name} must be a boolean", code="validation_failed") +def optional_bool_param(params: dict[str, object], name: str) -> bool | None: + if name not in params or params.get(name) in (None, ""): + return None + return bool_param(params, name) + + def confirm_param(params: dict[str, object], name: str) -> bool: if name in params: return bool_param(params, name) diff --git a/src/timecapsulesmb/services/deploy.py b/src/timecapsulesmb/services/deploy.py index 33f1ecac..985b9db7 100644 --- a/src/timecapsulesmb/services/deploy.py +++ b/src/timecapsulesmb/services/deploy.py @@ -39,7 +39,7 @@ def render_flash_runtime_config( payload_home: PayloadHome, *, nbns_enabled: bool, - debug_logging: bool, + debug_logging: bool | None = None, internal_share_use_disk_root: bool | None = None, any_protocol: bool | None = None, ata_idle_seconds: int = DEFAULT_ATA_IDLE_SECONDS, @@ -58,7 +58,7 @@ def render_flash_runtime_config( if any_protocol is None else any_protocol ) - effective_debug_logging = debug_logging or parse_bool(configured_debug_logging) + effective_debug_logging = parse_bool(configured_debug_logging) if debug_logging is None else debug_logging values: list[tuple[str, str | int]] = [ ("TC_CONFIG_VERSION", 2), diff --git a/tests/test_app_api.py b/tests/test_app_api.py index affb3dcc..37e9dc15 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -137,6 +137,22 @@ 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 assert_confirmation( + self, + collector: CollectingSink, + presentation_id: str, + presentation_values: dict[str, object] | None = None, + ) -> dict[str, object]: + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "confirmation_required") + details = error["details"] + self.assertEqual(details["presentation_id"], presentation_id) + self.assertTrue(details["message"].endswith("?"), details["message"]) + self.assertIn("confirmation_id", details) + for key, value in (presentation_values or {}).items(): + self.assertEqual(details["presentation_values"][key], value) + return details + def test_event_redacts_sensitive_fields(self) -> None: event = AppEvent("result", "configure", { "ok": True, @@ -749,7 +765,13 @@ 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.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.load_env_config", return_value=AppConfig.from_values({ + "TC_HOST": "root@10.0.0.2", + "TC_PASSWORD": "pw", + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true", + "TC_ANY_PROTOCOL": "true", + "TC_DEBUG_LOGGING": "true", + })): with mock.patch("timecapsulesmb.app.ops.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")]): @@ -790,7 +812,11 @@ def test_deploy_requires_reboot_confirmation_before_remote_actions(self) -> None ) self.assertEqual(rc, 1) - self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + self.assert_confirmation( + collector, + "deploy.reboot", + {"device_name": "Time Capsule", "requires_reboot": True, "no_reboot": False, "no_wait": False}, + ) remote_actions.assert_not_called() def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(self) -> None: @@ -816,7 +842,11 @@ def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(s ) self.assertEqual(rc, 1) - self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + self.assert_confirmation( + collector, + "deploy.netbsd4", + {"device_name": "Time Capsule", "netbsd4": True, "no_reboot": False, "no_wait": False}, + ) read_mast.assert_not_called() remote_actions.assert_not_called() @@ -842,10 +872,12 @@ def test_deploy_requires_deploy_confirmation_even_without_reboot(self) -> None: ) self.assertEqual(rc, 1) - error = self.assert_single_terminal_event(collector, "error") - self.assertEqual(error["code"], "confirmation_required") - self.assertEqual(error["details"]["action_title"], "Deploy") - self.assertIn("confirmation_id", error["details"]) + error = self.assert_confirmation( + collector, + "deploy.no_reboot", + {"device_name": "Time Capsule", "netbsd4": False, "no_reboot": True, "no_wait": False}, + ) + self.assertEqual(error["action_title"], "Deploy") read_mast.assert_not_called() def test_deploy_accepts_backend_confirmation_id_before_remote_writes(self) -> None: @@ -948,9 +980,9 @@ def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: "dry_run": False, "no_reboot": True, "confirm_deploy": True, - "internal_share_use_disk_root": True, - "any_protocol": True, - "debug_logging": True, + "internal_share_use_disk_root": False, + "any_protocol": False, + "debug_logging": False, }, }, collector.sink, @@ -960,9 +992,9 @@ def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: upload.assert_called_once() wait.assert_not_called() render_runtime.assert_called_once() - self.assertEqual(render_runtime.call_args.kwargs["internal_share_use_disk_root"], True) - self.assertEqual(render_runtime.call_args.kwargs["any_protocol"], True) - self.assertEqual(render_runtime.call_args.kwargs["debug_logging"], True) + self.assertEqual(render_runtime.call_args.kwargs["internal_share_use_disk_root"], False) + self.assertEqual(render_runtime.call_args.kwargs["any_protocol"], False) + self.assertEqual(render_runtime.call_args.kwargs["debug_logging"], False) self.assertEqual(collector.events_of_type("result")[0]["payload"]["rebooted"], False) def test_deploy_no_wait_requests_reboot_without_wait_or_runtime_verify(self) -> None: @@ -1118,7 +1150,7 @@ def test_activate_requires_explicit_confirmation(self) -> None: 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") + self.assert_confirmation(collector, "activate.netbsd4", {"netbsd4": True}) resolve_target.assert_not_called() runtime_probe.assert_not_called() remote_actions.assert_not_called() @@ -1154,7 +1186,37 @@ def test_uninstall_requires_confirmation_before_remote_removal(self) -> None: 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") + error = self.assert_confirmation( + collector, + "uninstall.reboot", + {"requires_reboot": True, "no_reboot": False, "no_wait": False}, + ) + self.assertEqual( + error["message"], + "Remove managed TimeCapsuleSMB files from the device and reboot it?", + ) + resolve_connection.assert_called_once() + uninstall.assert_not_called() + + def test_uninstall_without_reboot_requires_question_form_confirmation(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.ops.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": {"no_reboot": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_confirmation( + collector, + "uninstall.no_reboot", + {"requires_reboot": False, "no_reboot": True, "no_wait": False}, + ) + self.assertEqual(error["message"], "Remove managed TimeCapsuleSMB files from the device?") resolve_connection.assert_called_once() uninstall.assert_not_called() @@ -1242,9 +1304,22 @@ def test_fsck_requires_confirmation_before_remote_connection(self) -> None: 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") + self.assert_confirmation(collector, "fsck.reboot", {"requires_reboot": True, "no_reboot": False}) resolve_connection.assert_not_called() + def test_fsck_without_reboot_requires_question_form_confirmation(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config") as load_config: + rc = service.run_api_request( + {"operation": "fsck", "params": {"no_reboot": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation(collector, "fsck.no_reboot", {"requires_reboot": False, "no_reboot": True}) + load_config.assert_not_called() + def test_fsck_rejects_non_integer_mount_wait_before_remote_connection(self) -> None: for value in (12.5, True): with self.subTest(value=value): @@ -1481,9 +1556,8 @@ 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") + error = self.assert_confirmation(collector, "repair_xattrs", {"path": "/Volumes/Data"}) + self.assertEqual(collector.events_of_type("error")[0]["recovery"]["title"], "Repair confirmation required") runner.assert_not_called() def test_repair_xattrs_checks_platform_after_confirmation(self) -> None: diff --git a/tests/test_storage_runtime.py b/tests/test_storage_runtime.py index b1eecfee..3ad73ca5 100644 --- a/tests/test_storage_runtime.py +++ b/tests/test_storage_runtime.py @@ -903,12 +903,25 @@ def test_flash_runtime_config_uses_saved_debug_logging(self) -> None: config, PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), nbns_enabled=True, - debug_logging=False, + debug_logging=None, ) self.assertIn("SMBD_DEBUG_LOGGING=1\n", rendered) self.assertIn("MDNS_DEBUG_LOGGING=1\n", rendered) + def test_flash_runtime_config_deploy_time_debug_override_can_disable_saved_value(self) -> None: + config = AppConfig.from_values({"TC_DEBUG_LOGGING": "true"}) + + rendered = render_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=True, + debug_logging=False, + ) + + self.assertIn("SMBD_DEBUG_LOGGING=0\n", rendered) + self.assertIn("MDNS_DEBUG_LOGGING=0\n", rendered) + def test_flash_runtime_config_accepts_deploy_time_advanced_overrides(self) -> None: config = AppConfig.from_values( { From f3c2bad1d5736343c99c17a0c165caaaede38437 Mon Sep 17 00:00:00 2001 From: James Chang Date: Fri, 22 May 2026 00:57:00 -0700 Subject: [PATCH 028/129] Reboot NetBSD 4 deploys before activation --- .../Backend/BackendPayloads.swift | 17 ++ .../Backend/PendingConfirmation.swift | 7 +- .../Resources/en.lproj/Localizable.strings | 30 ++- .../Views/Dashboard/InstallTab.swift | 32 ++- .../Workflows/DeployWorkflowStore.swift | 36 ++- .../Workflows/InstallPresentation.swift | 74 +++++- .../Workflows/OperationTimeline.swift | 4 +- .../BackendPayloadTests.swift | 2 + .../DashboardPresentationTests.swift | 55 ++++- .../DeployWorkflowStoreTests.swift | 41 +++- .../OperationTimelineBuilderTests.swift | 10 + .../PendingConfirmationTests.swift | 49 ++++ .../StoreTestSupport.swift | 21 +- src/timecapsulesmb/app/ops/deploy.py | 121 +++++++--- src/timecapsulesmb/app/recovery.py | 24 +- src/timecapsulesmb/app/stage_policy.py | 3 + tests/test_app_api.py | 225 +++++++++++++++++- 17 files changed, 674 insertions(+), 77 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift index 3d617ccb..e816bb70 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift @@ -332,6 +332,19 @@ struct DeviceCompatibilityPayload: Decodable, Equatable { } } +enum DeployStartupMode: String, Decodable, Equatable { + case rebootThenVerify = "reboot_then_verify" + case rebootThenActivate = "reboot_then_activate" + case activateNow = "activate_now" + + static func fallback(netbsd4: Bool, requiresReboot: Bool) -> DeployStartupMode { + if !requiresReboot { + return .activateNow + } + return netbsd4 ? .rebootThenActivate : .rebootThenVerify + } +} + struct DeployPlanPayload: Decodable, Equatable { let schemaVersion: Int let host: String @@ -341,6 +354,7 @@ struct DeployPlanPayload: Decodable, Equatable { let netbsd4: Bool let requiresReboot: Bool let rebootRequired: Bool? + let startupMode: DeployStartupMode let uploads: [JSONValue] let preUploadActions: [JSONValue] let postUploadActions: [JSONValue] @@ -357,6 +371,7 @@ struct DeployPlanPayload: Decodable, Equatable { case netbsd4 case requiresReboot = "requires_reboot" case rebootRequired = "reboot_required" + case startupMode = "startup_mode" case uploads case preUploadActions = "pre_upload_actions" case postUploadActions = "post_upload_actions" @@ -375,6 +390,8 @@ struct DeployPlanPayload: Decodable, Equatable { self.netbsd4 = try container.decode(Bool.self, forKey: .netbsd4) self.requiresReboot = try container.decode(Bool.self, forKey: .requiresReboot) self.rebootRequired = try container.decodeIfPresent(Bool.self, forKey: .rebootRequired) + self.startupMode = try container.decodeIfPresent(DeployStartupMode.self, forKey: .startupMode) + ?? DeployStartupMode.fallback(netbsd4: netbsd4, requiresReboot: requiresReboot) self.uploads = try container.decodeIfPresent([JSONValue].self, forKey: .uploads) ?? [] self.preUploadActions = try container.decodeIfPresent([JSONValue].self, forKey: .preUploadActions) ?? [] self.postUploadActions = try container.decodeIfPresent([JSONValue].self, forKey: .postUploadActions) ?? [] diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift index c07fbfc9..ec4f03de 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift @@ -77,7 +77,12 @@ private struct ConfirmationPresentation { } let values = detailObject(details, "presentation_values") switch presentationKey { - case "deploy.netbsd4", "deploy.no_reboot", "deploy.reboot": + case "deploy.activate_now", + "deploy.netbsd4", + "deploy.netbsd4_no_wait", + "deploy.no_reboot", + "deploy.reboot", + "deploy.reboot_no_wait": guard let deviceName = stringValue(values, "device_name") else { return nil } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 48351b8b..8c7cb10e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -94,15 +94,24 @@ "confirm.activate.netbsd4.title" = "Activate NetBSD4 Runtime?"; "confirm.backend.message" = "Continue with this operation?"; "confirm.backend.title" = "Confirm Operation?"; -"confirm.deploy.netbsd4.action" = "Deploy and Activate"; -"confirm.deploy.netbsd4.message" = "Deploy and activate the NetBSD4 payload on this %@ and change remote services?"; -"confirm.deploy.netbsd4.title" = "Deploy And Activate NetBSD4?"; +"confirm.deploy.activate_now.action" = "Deploy and Start SMB"; +"confirm.deploy.activate_now.message" = "Deploy TimeCapsuleSMB to this %@ and start SMB without rebooting it?"; +"confirm.deploy.activate_now.title" = "Deploy And Start SMB?"; +"confirm.deploy.netbsd4.action" = "Deploy, Reboot, and Activate"; +"confirm.deploy.netbsd4.message" = "Deploy TimeCapsuleSMB to this %@, reboot it, then activate Samba after SSH returns?"; +"confirm.deploy.netbsd4.title" = "Deploy, Reboot, And Activate NetBSD4?"; +"confirm.deploy.netbsd4_no_wait.action" = "Deploy and Request Reboot"; +"confirm.deploy.netbsd4_no_wait.message" = "Deploy TimeCapsuleSMB to this %@, request reboot, and return immediately without running Samba activation after SSH returns?"; +"confirm.deploy.netbsd4_no_wait.title" = "Deploy And Request NetBSD4 Reboot?"; "confirm.deploy.no_reboot.action" = "Deploy"; "confirm.deploy.no_reboot.message" = "Deploy TimeCapsuleSMB to this %@ without rebooting it?"; "confirm.deploy.no_reboot.title" = "Deploy Without Reboot?"; "confirm.deploy.reboot.action" = "Deploy and Reboot"; "confirm.deploy.reboot.message" = "Deploy TimeCapsuleSMB and reboot this %@?"; "confirm.deploy.reboot.title" = "Deploy And Reboot?"; +"confirm.deploy.reboot_no_wait.action" = "Deploy and Request Reboot"; +"confirm.deploy.reboot_no_wait.message" = "Deploy TimeCapsuleSMB to this %@, request reboot, and return immediately?"; +"confirm.deploy.reboot_no_wait.title" = "Deploy And Request Reboot?"; "confirm.fsck.no_reboot.action" = "Run fsck"; "confirm.fsck.no_reboot.message" = "Run fsck on the selected HFS volume?"; "confirm.fsck.no_reboot.title" = "Run Disk Repair?"; @@ -181,7 +190,11 @@ "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 Activate to start Samba after every future reboot."; +"deploy.presentation.warning.netbsd4_activation" = "This NetBSD4 device needs an activation step before Samba is ready."; +"deploy.presentation.warning.netbsd4_activate_now" = "This NetBSD4 install will start Samba without rebooting."; +"deploy.presentation.warning.netbsd4_reboot_then_activate" = "This NetBSD4 install will reboot first, then start Samba after SSH returns."; +"deploy.presentation.warning.no_wait_post_reboot_activation" = "No Wait will return after requesting reboot. Samba activation will not run automatically after SSH returns."; +"deploy.presentation.warning.no_wait_post_reboot_verification" = "No Wait will return after requesting reboot. Post-reboot SMB verification will not run automatically."; "deploy.result.default_message" = "Install completed."; "deploy.result.message" = "Message"; "deploy.result.reboot_requested" = "Reboot Requested"; @@ -194,7 +207,9 @@ "install.completion.title.finished" = "Install / Update Finished"; "install.completion.title.verified" = "Install / Update Verified"; "install.completion.warning.netbsd4" = "NetBSD4 devices may need Activate after a later reboot unless the boot hook is patched."; -"install.plan.downtime.netbsd4" = "Usually under a minute; the runtime may start without reboot."; +"install.plan.downtime.activate_now" = "Usually under a minute while Samba starts without rebooting."; +"install.plan.downtime.netbsd4" = "Usually under a minute while Samba starts without rebooting."; +"install.plan.downtime.no_wait" = "The app will request reboot and return immediately."; "install.plan.downtime.none" = "No reboot expected."; "install.plan.downtime.reboot" = "Several minutes while the Time Capsule reboots."; "install.plan.row.disk" = "Disk"; @@ -204,8 +219,12 @@ "install.plan.section.device_actions" = "Device Actions"; "install.plan.section.files" = "Files"; "install.plan.section.target" = "Target"; +"install.plan.title.activate_now" = "Install / Update SMB and Start Runtime"; "install.plan.title.netbsd4" = "Install / Update SMB and Start Runtime"; +"install.plan.title.reboot_no_wait" = "Install / Update SMB and Request Reboot"; +"install.plan.title.reboot_then_activate" = "Install / Update SMB, Reboot, and Start Runtime"; "install.plan.title.standard" = "Install / Update SMB"; +"install.advanced_options.no_wait_note" = "No Wait is a power-user option: it requests reboot and returns immediately without post-reboot activation or verification."; "install.progress.deploying.message" = "Uploading and applying the managed SMB runtime. This can take a few minutes..."; "install.progress.deploying.title" = "Installing / Updating SMB"; "install.state.awaiting_confirmation" = "Review the confirmation dialog before continuing."; @@ -467,6 +486,7 @@ "timeline.result.done" = "Done"; "timeline.result.failed" = "Failed"; "timeline.stage.checking_bundled_files" = "Checking Bundled Files"; +"timeline.stage.checking_runtime" = "Checking SMB"; "timeline.stage.checking_ssh" = "Checking SSH"; "timeline.stage.enabling_ssh" = "Enabling SSH"; "timeline.stage.finding_disk" = "Finding Disk"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift index 454ce56f..a3e35210 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift @@ -15,6 +15,7 @@ struct InstallTab: View { error: store.error, events: store.events, currentStage: store.currentStage, + plannedOptions: store.plannedOptions, profile: profile, hostWarning: HostCompatibilityPolicy.warning(), isCheckupRunning: summary.displayStatus == .checking @@ -215,7 +216,16 @@ private struct InstallExecutionOptionsView: View { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) - Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + .disabled(!allowsNoReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: noWaitBinding) + .disabled(!allowsNoWait) + } + GridRow { + Text(L10n.string("install.advanced_options.no_wait_note")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .gridCellColumns(2) } GridRow { Text(L10n.string("field.mount_wait")) @@ -227,4 +237,24 @@ private struct InstallExecutionOptionsView: View { } .disabled(store.isBusy) } + + private var allowsNoReboot: Bool { + DeployExecutionOptionPolicy.allowsNoReboot(noWait: store.noWait) + } + + private var allowsNoWait: Bool { + DeployExecutionOptionPolicy.allowsNoWait(noReboot: store.noReboot) + } + + private var noWaitBinding: Binding { + Binding { + allowsNoWait ? store.noWait : false + } set: { value in + if allowsNoWait { + store.noWait = value + } else { + store.noWait = false + } + } + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift index 44ba775f..0ae416bf 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift @@ -11,6 +11,23 @@ struct DeployOptions: Equatable { let mountWait: Int } +enum DeployExecutionOptionPolicy { + static func allowsNoReboot(noWait: Bool) -> Bool { + !noWait + } + + static func allowsNoWait(noReboot: Bool) -> Bool { + !noReboot + } + + static func effectiveRebootOptions(noReboot: Bool, noWait: Bool) -> (noReboot: Bool, noWait: Bool) { + if noReboot { + return (true, false) + } + return (false, noWait) + } +} + enum DeployWorkflowState: String, CaseIterable, Equatable, Codable { case idle case planning @@ -52,10 +69,20 @@ final class DeployWorkflowStore: ObservableObject { didSet { reconcilePlanFreshness() } } @Published var noReboot = false { - didSet { reconcilePlanFreshness() } + didSet { + if noReboot && noWait { + noWait = false + } + reconcilePlanFreshness() + } } @Published var noWait = false { - didSet { reconcilePlanFreshness() } + didSet { + if noWait && noReboot { + noReboot = false + } + reconcilePlanFreshness() + } } @Published var internalShareUseDiskRoot = false { didSet { reconcilePlanFreshness() } @@ -252,10 +279,11 @@ final class DeployWorkflowStore: ObservableObject { guard let mountWaitValue else { return nil } + let rebootOptions = DeployExecutionOptionPolicy.effectiveRebootOptions(noReboot: noReboot, noWait: noWait) return DeployOptions( nbnsEnabled: nbnsEnabled, - noReboot: noReboot, - noWait: noWait, + noReboot: rebootOptions.noReboot, + noWait: rebootOptions.noWait, internalShareUseDiskRoot: internalShareUseDiskRoot, anyProtocol: anyProtocol, debugLogging: debugLogging, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift index 616ed32e..d35defc1 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift @@ -14,10 +14,14 @@ struct InstallPlanPresentation: Equatable { let sections: [InstallPlanSection] let warnings: [String] - init(plan: DeployPlanPayload, profile: DeviceProfile, hostWarning: HostCompatibilityWarning? = nil) { - self.title = plan.netbsd4 - ? L10n.string("install.plan.title.netbsd4") - : L10n.string("install.plan.title.standard") + init( + plan: DeployPlanPayload, + profile: DeviceProfile, + options: DeployOptions? = nil, + hostWarning: HostCompatibilityWarning? = nil + ) { + let returnsAfterRebootRequest = Self.returnsAfterRebootRequest(plan: plan, options: options) + self.title = Self.title(for: plan, returnsAfterRebootRequest: returnsAfterRebootRequest) self.sections = [ InstallPlanSection(title: L10n.string("install.plan.section.target"), rows: [ InstallPlanRow(label: L10n.string("deploy.presentation.row.target"), value: profile.title), @@ -31,14 +35,17 @@ struct InstallPlanPresentation: Equatable { ]), InstallPlanSection(title: L10n.string("install.plan.section.device_actions"), rows: [ InstallPlanRow(label: L10n.string("deploy.presentation.row.reboot"), value: plan.requiresReboot ? L10n.string("value.required") : L10n.string("value.not_required")), - InstallPlanRow(label: L10n.string("install.plan.row.expected_downtime"), value: Self.expectedDowntime(plan: plan)), + InstallPlanRow(label: L10n.string("install.plan.row.expected_downtime"), value: Self.expectedDowntime(plan: plan, returnsAfterRebootRequest: returnsAfterRebootRequest)), InstallPlanRow(label: L10n.string("install.plan.row.remote_actions"), value: "\(plan.preUploadActions.count + plan.postUploadActions.count + plan.activationActions.count)"), InstallPlanRow(label: L10n.string("deploy.presentation.row.post_install_checks"), value: "\(plan.postDeployChecks.count)") ]) ] var warnings: [String] = [] - if plan.netbsd4 { - warnings.append(L10n.string("deploy.presentation.warning.netbsd4_activation")) + if returnsAfterRebootRequest { + warnings.append(Self.noWaitWarning(for: plan)) + } + if plan.netbsd4 && !returnsAfterRebootRequest { + warnings.append(Self.netbsd4Warning(for: plan)) } if let hostWarning { warnings.append(hostWarning.message) @@ -46,14 +53,52 @@ struct InstallPlanPresentation: Equatable { self.warnings = warnings } - private static func expectedDowntime(plan: DeployPlanPayload) -> String { - if plan.requiresReboot { + private static func returnsAfterRebootRequest(plan: DeployPlanPayload, options: DeployOptions?) -> Bool { + plan.requiresReboot && options?.noWait == true + } + + private static func expectedDowntime(plan: DeployPlanPayload, returnsAfterRebootRequest: Bool) -> String { + if returnsAfterRebootRequest { + return L10n.string("install.plan.downtime.no_wait") + } + switch plan.startupMode { + case .rebootThenVerify, .rebootThenActivate: return L10n.string("install.plan.downtime.reboot") + case .activateNow: + return L10n.string("install.plan.downtime.activate_now") } - if plan.netbsd4 { - return L10n.string("install.plan.downtime.netbsd4") + } + + private static func title(for plan: DeployPlanPayload, returnsAfterRebootRequest: Bool) -> String { + if returnsAfterRebootRequest { + return L10n.string("install.plan.title.reboot_no_wait") + } + switch plan.startupMode { + case .rebootThenActivate: + return L10n.string("install.plan.title.reboot_then_activate") + case .activateNow: + return L10n.string("install.plan.title.activate_now") + case .rebootThenVerify: + return L10n.string("install.plan.title.standard") + } + } + + private static func noWaitWarning(for plan: DeployPlanPayload) -> String { + if plan.startupMode == .rebootThenActivate { + return L10n.string("deploy.presentation.warning.no_wait_post_reboot_activation") + } + return L10n.string("deploy.presentation.warning.no_wait_post_reboot_verification") + } + + private static func netbsd4Warning(for plan: DeployPlanPayload) -> String { + switch plan.startupMode { + case .rebootThenActivate: + return L10n.string("deploy.presentation.warning.netbsd4_reboot_then_activate") + case .activateNow: + return L10n.string("deploy.presentation.warning.netbsd4_activate_now") + case .rebootThenVerify: + return L10n.string("deploy.presentation.warning.netbsd4_activation") } - return L10n.string("install.plan.downtime.none") } } @@ -244,12 +289,15 @@ struct InstallWorkflowPresentation: Equatable { error: BackendErrorViewModel?, events: [BackendEvent], currentStage: OperationStageState?, + plannedOptions: DeployOptions? = nil, profile: DeviceProfile, hostWarning: HostCompatibilityWarning? = nil, isCheckupRunning: Bool = false ) { self.title = L10n.string("dashboard.tab.install") - self.plan = plan.map { InstallPlanPresentation(plan: $0, profile: profile, hostWarning: hostWarning) } + self.plan = plan.map { + InstallPlanPresentation(plan: $0, profile: profile, options: plannedOptions, hostWarning: hostWarning) + } self.timeline = Self.timeline(for: state, events: events, currentStage: currentStage) let persistedCompletion = Self.persistedCompletion( state: state, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift index e1a6f69a..4cf7f460 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift @@ -123,7 +123,9 @@ enum OperationTimelineBuilder { return L10n.string("timeline.stage.syncing_to_disk") case ("deploy", "reboot"), ("deploy", "wait_for_reboot_down"), ("deploy", "wait_for_reboot_up"): return L10n.string("timeline.stage.rebooting") - case ("deploy", "netbsd4_activation"): + case ("deploy", "probe_runtime"): + return L10n.string("timeline.stage.checking_runtime") + case ("deploy", "activate_runtime"), ("deploy", "post_reboot_activation"), ("deploy", "netbsd4_activation"): return L10n.string("timeline.stage.starting_smb") case ("deploy", "verify_runtime_activation"), ("deploy", "verify_runtime_reboot"): return L10n.string("timeline.stage.verifying_smb") diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift index 59f52ea6..ddf72920 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift @@ -155,6 +155,7 @@ final class BackendPayloadTests: XCTestCase { "netbsd4": false, "requires_reboot": true, "reboot_required": true, + "startup_mode": "reboot_then_verify", "uploads": [{"description": "smbd"}], "pre_upload_actions": [{"type": "stop_process"}], "post_upload_actions": [], @@ -166,6 +167,7 @@ final class BackendPayloadTests: XCTestCase { XCTAssertEqual(deployPlan.payloadFamily, "netbsd6_samba4") XCTAssertTrue(deployPlan.requiresReboot) + XCTAssertEqual(deployPlan.startupMode, .rebootThenVerify) XCTAssertEqual(deployPlan.uploads.count, 1) let deployResult = try jsonValue(""" diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift index 070b05b0..0e945f8b 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -304,16 +304,62 @@ final class DashboardPresentationTests: XCTestCase { let presentation = InstallPlanPresentation(plan: plan, profile: profile, hostWarning: warning) - XCTAssertEqual(presentation.title, "Install / Update SMB and Start Runtime") + XCTAssertEqual(presentation.title, "Install / Update SMB, Reboot, and Start Runtime") XCTAssertTrue(presentation.sections.contains { section in section.rows.contains(InstallPlanRow(label: "Remote Actions", value: "1")) }) XCTAssertTrue(presentation.sections.contains { section in - section.rows.contains(InstallPlanRow(label: "Expected Downtime", value: "Usually under a minute; the runtime may start without reboot.")) + section.rows.contains(InstallPlanRow(label: "Expected Downtime", value: "Several minutes while the Time Capsule reboots.")) }) XCTAssertEqual(presentation.warnings.count, 2) } + func testInstallPlanPresentationUsesActivateNowMode() throws { + let plan = try testDeployPlanPayload( + requiresReboot: false, + startupMode: .activateNow + ).decode(DeployPlanPayload.self) + let profile = try makeProfile(payloadFamily: "netbsd6_samba4") + + let presentation = InstallPlanPresentation(plan: plan, profile: profile) + + XCTAssertEqual(presentation.title, "Install / Update SMB and Start Runtime") + XCTAssertTrue(presentation.sections.contains { section in + section.rows.contains(InstallPlanRow( + label: "Expected Downtime", + value: "Usually under a minute while Samba starts without rebooting." + )) + }) + XCTAssertEqual(presentation.warnings, []) + } + + func testInstallPlanPresentationShowsNoWaitPostRebootImpact() throws { + let plan = try netbsd4DeployPlan().decode(DeployPlanPayload.self) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + let options = DeployOptions( + nbnsEnabled: true, + noReboot: false, + noWait: true, + internalShareUseDiskRoot: false, + anyProtocol: false, + debugLogging: false, + mountWait: 30 + ) + + let presentation = InstallPlanPresentation(plan: plan, profile: profile, options: options) + + XCTAssertEqual(presentation.title, "Install / Update SMB and Request Reboot") + XCTAssertTrue(presentation.sections.contains { section in + section.rows.contains(InstallPlanRow( + label: "Expected Downtime", + value: "The app will request reboot and return immediately." + )) + }) + XCTAssertEqual(presentation.warnings, [ + "No Wait will return after requesting reboot. Samba activation will not run automatically after SSH returns." + ]) + } + func testInstallWorkflowPresentationCoversAllDeployStates() throws { let profile = try makeProfile() let plan = try testDeployPlanPayload().decode(DeployPlanPayload.self) @@ -681,8 +727,9 @@ final class DashboardPresentationTests: XCTestCase { "payload_dir": .string("/Volumes/dk2/.samba4"), "payload_family": .string("netbsd4_samba4"), "netbsd4": .bool(true), - "requires_reboot": .bool(false), - "reboot_required": .bool(false), + "requires_reboot": .bool(true), + "reboot_required": .bool(true), + "startup_mode": .string("reboot_then_activate"), "uploads": .array([.object(["description": .string("smbd")])]), "pre_upload_actions": .array([]), "post_upload_actions": .array([]), diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift index 877bc57f..e733ab2b 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift @@ -38,7 +38,6 @@ final class DeployWorkflowStoreTests: XCTestCase { ]) let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) store.mountWait = "45" - store.noReboot = true store.noWait = true store.nbnsEnabled = false store.internalShareUseDiskRoot = true @@ -54,7 +53,7 @@ final class DeployWorkflowStoreTests: XCTestCase { 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_reboot"], .bool(false)) XCTAssertEqual(runner.calls[0].params["no_wait"], .bool(true)) XCTAssertEqual(runner.calls[0].params["nbns_enabled"], .bool(false)) XCTAssertEqual(runner.calls[0].params["internal_share_use_disk_root"], .bool(true)) @@ -64,6 +63,43 @@ final class DeployWorkflowStoreTests: XCTestCase { XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) } + func testNoRebootAndNoWaitAreMutuallyExclusive() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + store.noWait = true + + XCTAssertTrue(store.noWait) + XCTAssertFalse(store.noReboot) + XCTAssertFalse(DeployExecutionOptionPolicy.allowsNoReboot(noWait: store.noWait)) + XCTAssertTrue(DeployExecutionOptionPolicy.allowsNoWait(noReboot: store.noReboot)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + + XCTAssertEqual(runner.calls[0].params["no_reboot"], .bool(false)) + XCTAssertEqual(runner.calls[0].params["no_wait"], .bool(true)) + + store.noReboot = true + + XCTAssertTrue(store.noReboot) + XCTAssertFalse(store.noWait) + XCTAssertTrue(DeployExecutionOptionPolicy.allowsNoReboot(noWait: store.noWait)) + XCTAssertFalse(DeployExecutionOptionPolicy.allowsNoWait(noReboot: store.noReboot)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { runner.calls.count == 2 && store.state == .planReady } + + XCTAssertEqual(runner.calls[1].params["no_reboot"], .bool(true)) + XCTAssertEqual(runner.calls[1].params["no_wait"], .bool(false)) + } + func testRejectedPlanDoesNotEnterPlanning() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ @@ -441,6 +477,7 @@ final class DeployWorkflowStoreTests: XCTestCase { "netbsd4": .bool(false), "requires_reboot": .bool(true), "reboot_required": .bool(true), + "startup_mode": .string("reboot_then_verify"), "uploads": .array([.object(["description": .string("smbd")])]), "pre_upload_actions": .array([.object(["type": .string("stop_process")])]), "post_upload_actions": .array([]), diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift index cb5c7b58..c02e83e4 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift @@ -76,4 +76,14 @@ final class OperationTimelineBuilderTests: XCTestCase { XCTAssertEqual(OperationTimelineBuilder.operationTitle("paths"), "App Readiness") XCTAssertEqual(OperationTimelineBuilder.operationTitle("flash"), "Persistent NetBSD4 Boot Hook") } + + func testDeployStartupStagesAreUserFacing() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "probe_runtime"), + BackendEvent(type: "stage", operation: "deploy", stage: "post_reboot_activation"), + BackendEvent(type: "stage", operation: "deploy", stage: "verify_runtime_activation") + ]) + + XCTAssertEqual(timeline.map(\.title), ["Checking SMB", "Starting SMB", "Verifying SMB"]) + } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index cefd289c..c23d90c6 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -155,6 +155,55 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(confirmation.actionTitle, "Deploy and Reboot") } + func testPendingConfirmationUsesLocalizedActivationCopy() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message."), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("deploy.activate_now"), + "presentation_values": .object(["device_name": .string("Time Capsule")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Deploy And Start SMB?") + XCTAssertEqual(confirmation.message, "Deploy TimeCapsuleSMB to this Time Capsule and start SMB without rebooting it?") + XCTAssertEqual(confirmation.actionTitle, "Deploy and Start SMB") + } + + func testPendingConfirmationUsesLocalizedNoWaitDeployCopy() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message."), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("deploy.netbsd4_no_wait"), + "presentation_values": .object(["device_name": .string("Time Capsule")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Deploy And Request NetBSD4 Reboot?") + XCTAssertEqual( + confirmation.message, + "Deploy TimeCapsuleSMB to this Time Capsule, request reboot, and return immediately without running Samba activation after SSH returns?" + ) + XCTAssertEqual(confirmation.actionTitle, "Deploy and Request Reboot") + } + func testPendingConfirmationUsesLocalizedQuestionForUninstallWithoutReboot() throws { let event = BackendEvent( type: "error", diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift index 59edc568..2974dc4b 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -361,16 +361,27 @@ func testDoctorCheck(status: String, message: String, domain: String) -> JSONVal ]) } -func testDeployPlanPayload(payloadFamily: String = "netbsd6_samba4") -> JSONValue { - .object([ +func testDeployPlanPayload( + payloadFamily: String = "netbsd6_samba4", + netbsd4: Bool? = nil, + requiresReboot: Bool = true, + startupMode: DeployStartupMode? = nil +) -> JSONValue { + let isNetBSD4 = netbsd4 ?? payloadFamily.localizedCaseInsensitiveContains("netbsd4") + let resolvedStartupMode = startupMode ?? DeployStartupMode.fallback( + netbsd4: isNetBSD4, + requiresReboot: requiresReboot + ) + return .object([ "schema_version": .number(1), "host": .string("root@10.0.0.2"), "volume_root": .string("/Volumes/dk2"), "payload_dir": .string("/Volumes/dk2/.samba4"), "payload_family": .string(payloadFamily), - "netbsd4": .bool(false), - "requires_reboot": .bool(true), - "reboot_required": .bool(true), + "netbsd4": .bool(isNetBSD4), + "requires_reboot": .bool(requiresReboot), + "reboot_required": .bool(requiresReboot), + "startup_mode": .string(resolvedStartupMode.rawValue), "uploads": .array([.object(["description": .string("smbd")])]), "pre_upload_actions": .array([]), "post_upload_actions": .array([]), diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index 389321ce..1f8a3c55 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -1,6 +1,7 @@ from __future__ import annotations from contextlib import ExitStack +from dataclasses import dataclass from pathlib import Path import tempfile @@ -98,6 +99,17 @@ ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 +@dataclass(frozen=True) +class DeployConfirmationPresentation: + title: str + message: str + action_title: str + risk: str + summary: str + presentation_id: str + legacy_names: tuple[str, ...] + + def startup_mode_for_deploy(*, no_reboot: bool, is_netbsd4: bool) -> DeploymentStartupMode: if no_reboot: return DEPLOY_STARTUP_ACTIVATE_NOW @@ -106,12 +118,76 @@ def startup_mode_for_deploy(*, no_reboot: bool, is_netbsd4: bool) -> DeploymentS return DEPLOY_STARTUP_REBOOT_THEN_VERIFY +def effective_no_wait_for_deploy(*, requested: bool, no_reboot: bool) -> bool: + return False if no_reboot else requested + + def activation_complete_message(*, is_netbsd4: bool) -> str: if is_netbsd4: return f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}" return "Runtime activation complete." +def confirmation_presentation_for_startup_mode( + *, + startup_mode: DeploymentStartupMode, + no_wait: bool, + device_name: str, +) -> DeployConfirmationPresentation: + if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: + if no_wait: + return DeployConfirmationPresentation( + title="Confirm NetBSD4 deployment and reboot request", + message=( + f"Deploy TimeCapsuleSMB to this {device_name}, request reboot, and return immediately " + "without running Samba activation after SSH returns?" + ), + action_title="Deploy and request reboot", + risk="reboot", + summary="NetBSD4 deployment with reboot request and no post-reboot activation wait", + presentation_id="deploy.netbsd4_no_wait", + legacy_names=("confirm_deploy",), + ) + return DeployConfirmationPresentation( + title="Confirm NetBSD4 deployment", + message=f"Deploy TimeCapsuleSMB to this {device_name}, reboot it, then activate Samba after SSH returns?", + action_title="Deploy, reboot, and activate", + risk="reboot", + summary="NetBSD4 deployment with reboot and service activation", + presentation_id="deploy.netbsd4", + legacy_names=("confirm_deploy", "confirm_netbsd4_activation"), + ) + if startup_mode == DEPLOY_STARTUP_ACTIVATE_NOW: + return DeployConfirmationPresentation( + title="Confirm deployment and runtime start", + message=f"Deploy TimeCapsuleSMB to this {device_name} and start Samba without rebooting it?", + action_title="Deploy and start SMB", + risk="remote_write", + summary="Deployment without reboot and runtime start", + presentation_id="deploy.activate_now", + legacy_names=("confirm_deploy",), + ) + if no_wait: + return DeployConfirmationPresentation( + title="Confirm deployment and reboot request", + message=f"Deploy TimeCapsuleSMB to this {device_name}, request reboot, and return immediately?", + action_title="Deploy and request reboot", + risk="reboot", + summary="Deployment with reboot request and no post-reboot verification wait", + presentation_id="deploy.reboot_no_wait", + legacy_names=("confirm_deploy",), + ) + return DeployConfirmationPresentation( + title="Confirm deployment and reboot", + message=f"Deploy TimeCapsuleSMB and reboot this {device_name}?", + action_title="Deploy and reboot", + risk="reboot", + summary="Deployment with reboot request", + presentation_id="deploy.reboot", + legacy_names=("confirm_deploy", "confirm_reboot"), + ) + + def require_supported_payload(target: ManagedTargetState, *, allow_unsupported: bool) -> DeviceCompatibility: probe_state = target.probe_state if probe_state is None: @@ -181,6 +257,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes payload_family = compatibility.payload_family is_netbsd4 = is_netbsd4_payload_family(payload_family) startup_mode = startup_mode_for_deploy(no_reboot=no_reboot, is_netbsd4=is_netbsd4) + no_wait = effective_no_wait_for_deploy(requested=no_wait, no_reboot=no_reboot) 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: @@ -197,27 +274,11 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes 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 TimeCapsuleSMB to this {device_name}, reboot it, then activate Samba after SSH returns?" - action_title = "Deploy, reboot, and activate" - risk = "reboot" - summary = "NetBSD4 deployment with reboot and service activation" - presentation_id = "deploy.netbsd4" - 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" - presentation_id = "deploy.no_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" - presentation_id = "deploy.reboot" + presentation = confirmation_presentation_for_startup_mode( + startup_mode=startup_mode, + no_wait=no_wait, + device_name=device_name, + ) presentation_values = { "device_name": device_name, "netbsd4": is_netbsd4, @@ -231,11 +292,11 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes build_confirmation( operation=operation, params=params, - title=title, - message=message, - action_title=action_title, - risk=risk, - summary=summary, + title=presentation.title, + message=presentation.message, + action_title=presentation.action_title, + risk=presentation.risk, + summary=presentation.summary, context={ "host": connection.host, "payload_family": payload_family, @@ -245,14 +306,10 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes "no_wait": no_wait, "startup_mode": startup_mode, }, - presentation_id=presentation_id, + presentation_id=presentation.presentation_id, presentation_values=presentation_values, ), - legacy_names=( - ("confirm_deploy", "confirm_netbsd4_activation") - if is_netbsd4 - else ("confirm_deploy",) if no_reboot else ("confirm_deploy", "confirm_reboot") - ), + legacy_names=presentation.legacy_names, ) if dry_run: payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) diff --git a/src/timecapsulesmb/app/recovery.py b/src/timecapsulesmb/app/recovery.py index 481f1f55..6a15e1fc 100644 --- a/src/timecapsulesmb/app/recovery.py +++ b/src/timecapsulesmb/app/recovery.py @@ -240,13 +240,29 @@ def to_jsonable(self) -> dict[str, object]: suggested_operation="doctor", action_ids=("run_checkup",), ), + ("deploy", "remote_error", "activate_runtime"): RecoveryInfo( + "Runtime activation failed", + "The deployed Samba runtime could not be started without rebooting.", + ("Retry install/update.", "Run doctor for detailed runtime checks."), + retryable=True, + suggested_operation="deploy", + action_ids=("install_smb", "run_checkup"), + ), + ("deploy", "remote_error", "post_reboot_activation"): RecoveryInfo( + "Post-reboot activation failed", + "The device rebooted, but the deployed Samba runtime could not be started after SSH returned.", + ("Retry install/update.", "Run doctor for detailed runtime checks."), + retryable=True, + suggested_operation="deploy", + action_ids=("install_smb", "run_checkup"), + ), ("deploy", "remote_error", "verify_runtime_activation"): RecoveryInfo( "Activated runtime not ready", - "The NetBSD4 runtime was started but did not become healthy.", - ("Retry activation.", "Run doctor for detailed runtime checks."), + "The deployed Samba runtime was started but did not become healthy.", + ("Retry install/update.", "Run doctor for detailed runtime checks."), retryable=True, - suggested_operation="doctor", - action_ids=("start_smb", "run_checkup"), + suggested_operation="deploy", + action_ids=("install_smb", "run_checkup"), ), ("uninstall", "remote_error", "verify_post_uninstall"): RecoveryInfo( "Post-uninstall verification failed", diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py index 3ea3c875..88354142 100644 --- a/src/timecapsulesmb/app/stage_policy.py +++ b/src/timecapsulesmb/app/stage_policy.py @@ -63,6 +63,9 @@ def to_jsonable(self) -> dict[str, object]: ("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", "probe_runtime"): StagePolicy(REMOTE_READ, True, "Check whether the deployed runtime is already ready."), + ("deploy", "activate_runtime"): StagePolicy(REMOTE_WRITE, False, "Start the deployed runtime without reboot."), + ("deploy", "post_reboot_activation"): StagePolicy(REMOTE_WRITE, False, "Start the deployed runtime after reboot."), ("deploy", "verify_runtime_activation"): StagePolicy(REMOTE_READ, True, "Wait for the activated runtime to become ready."), ("deploy", "reboot"): StagePolicy(REBOOT, False, "Request a device reboot."), ("deploy", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after reboot request."), diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 43af6db3..296ad295 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -807,6 +807,7 @@ 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) self.assertEqual(result["payload"]["requires_reboot"], True) + self.assertEqual(result["payload"]["startup_mode"], "reboot_then_verify") self.assertEqual(result["payload"]["payload_family"], "netbsd6_samba4") self.assertEqual(result["payload"]["schema_version"], 1) @@ -835,7 +836,13 @@ def test_deploy_requires_reboot_confirmation_before_remote_actions(self) -> None self.assert_confirmation( collector, "deploy.reboot", - {"device_name": "Time Capsule", "requires_reboot": True, "no_reboot": False, "no_wait": False}, + { + "device_name": "Time Capsule", + "requires_reboot": True, + "no_reboot": False, + "no_wait": False, + "startup_mode": "reboot_then_verify", + }, ) remote_actions.assert_not_called() @@ -865,11 +872,93 @@ def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(s self.assert_confirmation( collector, "deploy.netbsd4", - {"device_name": "Time Capsule", "netbsd4": True, "no_reboot": False, "no_wait": False}, + { + "device_name": "Time Capsule", + "netbsd4": True, + "no_reboot": False, + "no_wait": False, + "startup_mode": "reboot_then_activate", + }, ) read_mast.assert_not_called() remote_actions.assert_not_called() + def test_deploy_no_wait_confirmation_uses_reboot_request_copy(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.ops.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, "no_wait": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation( + collector, + "deploy.reboot_no_wait", + { + "device_name": "Time Capsule", + "requires_reboot": True, + "no_reboot": False, + "no_wait": True, + "startup_mode": "reboot_then_verify", + }, + ) + read_mast.assert_not_called() + remote_actions.assert_not_called() + + def test_deploy_netbsd4_no_wait_confirmation_does_not_promise_activation(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4-netbsd4be/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns-netbsd4be/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns-netbsd4be/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.ops.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, "no_wait": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + details = self.assert_confirmation( + collector, + "deploy.netbsd4_no_wait", + { + "device_name": "Time Capsule", + "netbsd4": True, + "requires_reboot": True, + "no_reboot": False, + "no_wait": True, + "startup_mode": "reboot_then_activate", + }, + ) + self.assertIn("without running Samba activation", details["message"]) + read_mast.assert_not_called() + remote_actions.assert_not_called() + def test_deploy_requires_deploy_confirmation_even_without_reboot(self) -> None: collector = CollectingSink() connection = SshConnection("root@10.0.0.2", "pw", "-o foo") @@ -894,12 +983,94 @@ def test_deploy_requires_deploy_confirmation_even_without_reboot(self) -> None: self.assertEqual(rc, 1) error = self.assert_confirmation( collector, - "deploy.no_reboot", - {"device_name": "Time Capsule", "netbsd4": False, "no_reboot": True, "no_wait": False}, + "deploy.activate_now", + { + "device_name": "Time Capsule", + "netbsd4": False, + "no_reboot": True, + "no_wait": False, + "startup_mode": "activate_now", + }, ) - self.assertEqual(error["action_title"], "Deploy") + self.assertEqual(error["action_title"], "Deploy and start SMB") read_mast.assert_not_called() + def test_deploy_no_reboot_no_wait_confirmation_treats_no_wait_as_inapplicable(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.ops.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, "no_wait": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation( + collector, + "deploy.activate_now", + { + "device_name": "Time Capsule", + "netbsd4": False, + "no_reboot": True, + "no_wait": False, + "startup_mode": "activate_now", + }, + ) + read_mast.assert_not_called() + + def test_deploy_netbsd4_no_reboot_uses_activate_now_confirmation(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4-netbsd4be/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns-netbsd4be/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns-netbsd4be/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.ops.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, "no_reboot": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation( + collector, + "deploy.activate_now", + { + "device_name": "Time Capsule", + "netbsd4": True, + "requires_reboot": False, + "no_reboot": True, + "no_wait": False, + "startup_mode": "activate_now", + }, + ) + read_mast.assert_not_called() + remote_actions.assert_not_called() + def test_deploy_accepts_backend_confirmation_id_before_remote_writes(self) -> None: first = CollectingSink() second = CollectingSink() @@ -1069,6 +1240,50 @@ def test_deploy_no_wait_requests_reboot_without_wait_or_runtime_verify(self) -> self.assertEqual(payload["waited"], False) self.assertEqual(payload["verified"], False) + def test_deploy_netbsd4_no_wait_requests_reboot_without_activation(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4-netbsd4be/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns-netbsd4be/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns-netbsd4be/nbns-advertiser"), + } + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_reboot") as reboot: + with mock.patch("timecapsulesmb.app.ops.deploy.activate_deployed_runtime") as activate: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + reboot.assert_called_once() + activate.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") From 31ef820696a38c464824e570b563c279c47e01b4 Mon Sep 17 00:00:00 2001 From: James Chang Date: Fri, 22 May 2026 01:04:46 -0700 Subject: [PATCH 029/129] Update doctor report to reboot --- src/timecapsulesmb/checks/doctor_steps.py | 2 +- tests/test_checks.py | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/timecapsulesmb/checks/doctor_steps.py b/src/timecapsulesmb/checks/doctor_steps.py index 46504d2e..19431e16 100644 --- a/src/timecapsulesmb/checks/doctor_steps.py +++ b/src/timecapsulesmb/checks/doctor_steps.py @@ -599,7 +599,7 @@ def _doctor_check_deployed_version(target: DoctorTarget, remote: RemoteAccess, s try: deployed_version = read_deployed_version_conn(target.connection) except Exception as e: - sink.add(CheckResult("FAIL", f"deployed payload version probe failed: {e}")) + sink.add(CheckResult("FAIL", f"deployed payload version probe failed; reboot the device and rerun doctor: {e}")) return StepDecision(stop=True) if sink.debug_fields is not None: diff --git a/tests/test_checks.py b/tests/test_checks.py index ea749720..3b366cc0 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -58,7 +58,7 @@ BonjourResolvedService, BonjourServiceInstance, ) -from timecapsulesmb.transport.ssh import SshConnection, SshError +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError DEFAULT_SMB_PORT_CHECK = object() @@ -467,6 +467,25 @@ def test_run_doctor_checks_stops_when_deployed_version_metadata_is_missing(self) ) managed_smbd.assert_not_called() + def test_run_doctor_checks_tells_user_to_reboot_when_deployed_version_probe_fails(self) -> None: + managed_smbd = mock.Mock() + error = SshCommandTimeout( + "Timed out waiting for ssh command to finish: /bin/sh -c 'config=/mnt/Flash/tcapsulesmb.conf; ...'" + ) + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + extra_patches={ + "timecapsulesmb.checks.doctor_steps.read_deployed_version_conn": mock.Mock(side_effect=error), + "timecapsulesmb.checks.doctor_steps.probe_managed_smbd_conn": managed_smbd, + }, + ) + + self.assertTrue(run.fatal) + self.assertIn("deployed payload version probe failed", run.results[-1].message) + self.assertIn("reboot the device and rerun doctor", run.results[-1].message) + self.assertIn("/mnt/Flash/tcapsulesmb.conf", run.results[-1].message) + managed_smbd.assert_not_called() + def test_run_doctor_checks_stops_when_deployed_version_is_older(self) -> None: managed_smbd = mock.Mock() run = self.run_doctor_with_mocks( From 6670b4c91082d27631d37b6483cf39d795638461 Mon Sep 17 00:00:00 2001 From: James Chang Date: Fri, 22 May 2026 01:07:44 -0700 Subject: [PATCH 030/129] Add deletion warning --- src/timecapsulesmb/cli/deploy.py | 1 + tests/test_cli.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/timecapsulesmb/cli/deploy.py b/src/timecapsulesmb/cli/deploy.py index ae899a29..2e2717b3 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -214,6 +214,7 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 + print("Deleting old deployed files...") command_context.set_stage("pre_upload_actions") run_remote_actions(connection, plan.pre_upload_actions) command_context.set_stage("prepare_deployment_files") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3bb6763b..3387837e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4608,6 +4608,7 @@ def test_deploy_selects_payload_home_from_mast_for_real_deploy(self) -> None: result.mocks.flush_remote_filesystem_writes.assert_called_once_with( result.mocks.wait_for_mast_volumes_conn.call_args.args[0] ) + self.assertIn("Deleting old deployed files...", result.text) self.assertIn("Flushing deployed payload to disk...", result.text) self.assertIn("Deployed Samba payload to /Volumes/dk2/.samba4", result.text) self.assertIn("Updated /mnt/Flash boot files.", result.text) From ed5bc76dd49e159eb226436b458540e2d23a1dd6 Mon Sep 17 00:00:00 2001 From: James Chang Date: Fri, 22 May 2026 01:52:36 -0700 Subject: [PATCH 031/129] Add TC_ATA_IDLE_SECONDS and TC_ATA_STANDBY to configure --- .env.example | 6 ++ DETAIL.md | 6 ++ src/timecapsulesmb/app/ops/configure.py | 49 ++++++------ src/timecapsulesmb/app/ops/deploy.py | 22 ++++-- .../assets/boot/samba4/common.d/00-env-log.sh | 1 + .../samba4/common.d/40-storage-discovery.sh | 71 +++++++++++++---- .../samba4/common.d/50-runtime-staging.sh | 4 +- src/timecapsulesmb/cli/configure.py | 26 +++++++ src/timecapsulesmb/cli/deploy.py | 1 + src/timecapsulesmb/core/config.py | 18 +++++ src/timecapsulesmb/services/configure.py | 26 +++++++ src/timecapsulesmb/services/deploy.py | 42 +++++++++- tests/test_cli.py | 50 ++++++++++++ tests/test_config.py | 10 +++ tests/test_storage_runtime.py | 77 +++++++++++++++++-- 15 files changed, 350 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index da38e8ae..1ceb5e05 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,9 @@ TC_PASSWORD='' 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' + +# Drive timer settings are applied to built-in ATA disks during runtime startup. +# Set TC_ATA_IDLE_SECONDS=0 to disable the ATA idle timer. +# Leave TC_ATA_STANDBY blank to avoid changing standby, or set 0 to disable it. +TC_ATA_IDLE_SECONDS='300' +TC_ATA_STANDBY='' diff --git a/DETAIL.md b/DETAIL.md index 8c7bb6ed..10957bfa 100644 --- a/DETAIL.md +++ b/DETAIL.md @@ -630,6 +630,8 @@ Current important `.env` values include: - `TC_PASSWORD` - `TC_SSH_OPTS` - `TC_INTERNAL_SHARE_USE_DISK_ROOT` +- `TC_ATA_IDLE_SECONDS` +- `TC_ATA_STANDBY` - `TC_CONFIGURE_ID` Current `.bootstrap` values include: @@ -651,6 +653,8 @@ Optional deploy flag: Current defaults: - `TC_INTERNAL_SHARE_USE_DISK_ROOT=false` +- `TC_ATA_IDLE_SECONDS=300` +- `TC_ATA_STANDBY=` leaves the standby timer unchanged; set `0` to disable standby - `TC_SSH_OPTS` includes the legacy SSH algorithms required by AirPort firmware - docs and examples use SMB username `admin` - the managed payload directory is fixed at `.samba4` @@ -662,6 +666,8 @@ Current validation behavior: - `TC_PASSWORD`: must be present for commands that authenticate to the device or generate Samba auth. - `TC_SSH_OPTS`: is written by `configure` with the legacy SSH options needed for AirPort firmware. - `TC_INTERNAL_SHARE_USE_DISK_ROOT`: hidden boolean; internal disks use `ShareRoot` by default, and external disks always use the disk root. +- `TC_ATA_IDLE_SECONDS`: optional non-negative integer; default `300`, and `0` disables the ATA idle timer through `atactl setidle 0`. +- `TC_ATA_STANDBY`: optional non-negative integer; blank leaves standby unchanged, and `0` disables standby through `atactl setstandby 0`. - `TC_CONFIGURE_ID`: is a local configuration revision ID and is not user-validated. Workflow details: diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py index d7e999ca..38a79701 100644 --- a/src/timecapsulesmb/app/ops/configure.py +++ b/src/timecapsulesmb/app/ops/configure.py @@ -55,28 +55,33 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation if resolution_error is not None: raise AppOperationError(resolution_error, code="config_error") - values = build_configure_env_values( - existing, - host=host, - password=password, - ssh_opts=ssh_opts, - configure_id=configure_id, - internal_share_use_disk_root=bool_param( - params, - "internal_share_use_disk_root", - parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), - ), - any_protocol=bool_param( - params, - "any_protocol", - parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), - ), - debug_logging=bool_param( - params, - "debug_logging", - parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])), - ), - ) + try: + values = build_configure_env_values( + existing, + host=host, + password=password, + ssh_opts=ssh_opts, + configure_id=configure_id, + internal_share_use_disk_root=bool_param( + params, + "internal_share_use_disk_root", + parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), + ), + any_protocol=bool_param( + params, + "any_protocol", + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ), + debug_logging=bool_param( + params, + "debug_logging", + parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])), + ), + ata_idle_seconds=string_param(params, "ata_idle_seconds") if "ata_idle_seconds" in params else None, + ata_standby=string_param(params, "ata_standby") if "ata_standby" in params else None, + ) + except ValueError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc sink.stage(operation, "ssh_probe") connection = SshConnection(host, password, ssh_opts) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index 1f8a3c55..445c0b98 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -82,6 +82,7 @@ config_path, int_param, optional_bool_param, + string_param, ) from timecapsulesmb.services.credentials import overlay_request_credentials from timecapsulesmb.services.deploy import ( @@ -362,14 +363,19 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes 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, - internal_share_use_disk_root=internal_share_use_disk_root, - any_protocol=any_protocol, - ) + try: + flash_config_text = render_flash_runtime_config( + config, + payload_home, + nbns_enabled=nbns_enabled, + debug_logging=debug_logging, + internal_share_use_disk_root=internal_share_use_disk_root, + any_protocol=any_protocol, + ata_idle_seconds=string_param(params, "ata_idle_seconds") if "ata_idle_seconds" in params else None, + ata_standby=string_param(params, "ata_standby") if "ata_standby" in params else None, + ) + except ValueError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: tmpdir = Path(tmp) generated_flash_config = tmpdir / "tcapsulesmb.conf" diff --git a/src/timecapsulesmb/assets/boot/samba4/common.d/00-env-log.sh b/src/timecapsulesmb/assets/boot/samba4/common.d/00-env-log.sh index ec1c00cc..8704b1b8 100644 --- a/src/timecapsulesmb/assets/boot/samba4/common.d/00-env-log.sh +++ b/src/timecapsulesmb/assets/boot/samba4/common.d/00-env-log.sh @@ -136,6 +136,7 @@ tc_init_runtime_env() { tc_add_runtime_env_warning "runtime config: invalid DISKD_USE_VOLUME_MOUNT_POLL_SECONDS=$DISKD_USE_VOLUME_MOUNT_POLL_SECONDS; using 3s" fi ATA_IDLE_SECONDS=${ATA_IDLE_SECONDS:-300} + ATA_STANDBY=${ATA_STANDBY:-} MAST_DISCOVERY_WAIT_SECONDS=${MAST_DISCOVERY_WAIT_SECONDS:-120} WATCHDOG_DISKD_USE_VOLUME_ATTEMPTS=${WATCHDOG_DISKD_USE_VOLUME_ATTEMPTS:-$DISKD_USE_VOLUME_ATTEMPTS} WATCHDOG_TOPOLOGY_DEBOUNCE_SECONDS=${WATCHDOG_TOPOLOGY_DEBOUNCE_SECONDS:-5} diff --git a/src/timecapsulesmb/assets/boot/samba4/common.d/40-storage-discovery.sh b/src/timecapsulesmb/assets/boot/samba4/common.d/40-storage-discovery.sh index 741f7354..10b53eee 100644 --- a/src/timecapsulesmb/assets/boot/samba4/common.d/40-storage-discovery.sh +++ b/src/timecapsulesmb/assets/boot/samba4/common.d/40-storage-discovery.sh @@ -152,16 +152,51 @@ tc_mount_mast_volumes_for_boot() { tc_log "boot disk load: diskd activation complete: total=$volume_count mounted=$mounted_count failed=$failed_count" } -tc_configure_ata_idle_for_mast_disks() { - volumes_file=$1 +tc_apply_ata_drive_setting() { + disk_device=$1 + atactl_command=$2 + timer_name=$3 + timer_value=$4 + volume_root=$5 + + if [ "$timer_value" = "0" ]; then + tc_log "ATA drive settings: disabling $disk_device $timer_name timer after mounted volume $volume_root" + else + tc_log "ATA drive settings: setting $disk_device $timer_name timer to ${timer_value}s after mounted volume $volume_root" + fi + if /sbin/atactl "$disk_device" "$atactl_command" "$timer_value" >/dev/null 2>&1; then + if [ "$timer_value" = "0" ]; then + tc_log "ATA drive settings: disabled $disk_device $timer_name timer" + else + tc_log "ATA drive settings: set $disk_device $timer_name timer to ${timer_value}s" + fi + else + tc_log "ATA drive settings: failed to set $disk_device $timer_name timer to ${timer_value}s" + fi +} - tc_log "ATA idle tuning: scanning built-in ATA disks after share-state build" - if ! tc_is_unsigned_integer "$ATA_IDLE_SECONDS"; then - tc_log "ATA idle tuning skipped; invalid ATA_IDLE_SECONDS=$ATA_IDLE_SECONDS" - return 0 +tc_configure_ata_drive_settings_for_mast_disks() { + volumes_file=$1 + tc_ata_idle_value=${ATA_IDLE_SECONDS:-300} + tc_ata_standby_value=${ATA_STANDBY:-} + tc_ata_apply_idle=0 + tc_ata_apply_standby=0 + + tc_log "ATA drive settings: scanning built-in ATA disks after share-state build" + if tc_is_unsigned_integer "$tc_ata_idle_value"; then + tc_ata_apply_idle=1 + else + tc_log "ATA drive settings: idle tuning skipped; invalid ATA_IDLE_SECONDS=$tc_ata_idle_value" + fi + if [ -n "$tc_ata_standby_value" ]; then + if tc_is_unsigned_integer "$tc_ata_standby_value"; then + tc_ata_apply_standby=1 + else + tc_log "ATA drive settings: standby tuning skipped; invalid ATA_STANDBY=$tc_ata_standby_value" + fi fi - if [ "$ATA_IDLE_SECONDS" -eq 0 ]; then - tc_log "ATA idle tuning disabled" + if [ "$tc_ata_apply_idle" != "1" ] && [ "$tc_ata_apply_standby" != "1" ]; then + tc_log "ATA drive settings: no valid drive settings configured" return 0 fi @@ -170,18 +205,18 @@ tc_configure_ata_idle_for_mast_disks() { [ -n "$disk_device$builtin$part_device$volume_root$part_name$part_uuid" ]; do [ -n "$disk_device" ] || continue if [ "$builtin" != "1" ]; then - tc_log "ATA idle tuning: skipping $disk_device for /dev/$part_device; MaSt marks disk as external" + tc_log "ATA drive settings: skipping $disk_device for /dev/$part_device; MaSt marks disk as external" continue fi case "$disk_device" in wd[0-9]*) ;; *) - tc_log "ATA idle tuning: skipping $disk_device for /dev/$part_device; not a wd ATA disk" + tc_log "ATA drive settings: skipping $disk_device for /dev/$part_device; not a wd ATA disk" continue ;; esac if ! is_volume_root_mounted "$volume_root"; then - tc_log "ATA idle tuning: skipping $disk_device for /dev/$part_device; $volume_root is not mounted" + tc_log "ATA drive settings: skipping $disk_device for /dev/$part_device; $volume_root is not mounted" continue fi case "$configured_disks" in @@ -189,15 +224,19 @@ tc_configure_ata_idle_for_mast_disks() { esac configured_disks="$configured_disks$disk_device " - tc_log "ATA idle tuning: setting $disk_device idle timer to ${ATA_IDLE_SECONDS}s after mounted volume $volume_root" - if /sbin/atactl "$disk_device" setidle "$ATA_IDLE_SECONDS" >/dev/null 2>&1; then - tc_log "ATA idle tuning: set $disk_device idle timer to ${ATA_IDLE_SECONDS}s" - else - tc_log "ATA idle tuning: failed to set $disk_device idle timer to ${ATA_IDLE_SECONDS}s" + if [ "$tc_ata_apply_idle" = "1" ]; then + tc_apply_ata_drive_setting "$disk_device" setidle idle "$tc_ata_idle_value" "$volume_root" + fi + if [ "$tc_ata_apply_standby" = "1" ]; then + tc_apply_ata_drive_setting "$disk_device" setstandby standby "$tc_ata_standby_value" "$volume_root" fi done <"$volumes_file" } +tc_configure_ata_idle_for_mast_disks() { + tc_configure_ata_drive_settings_for_mast_disks "$1" +} + tc_plist_key() { printf '%s\n' "$1" | /usr/bin/sed -n 's/^[[:space:]]*\([A-Za-z][A-Za-z0-9_]*\)[[:space:]]*=.*/\1/p' } diff --git a/src/timecapsulesmb/assets/boot/samba4/common.d/50-runtime-staging.sh b/src/timecapsulesmb/assets/boot/samba4/common.d/50-runtime-staging.sh index dcc8eed9..41e404b3 100644 --- a/src/timecapsulesmb/assets/boot/samba4/common.d/50-runtime-staging.sh +++ b/src/timecapsulesmb/assets/boot/samba4/common.d/50-runtime-staging.sh @@ -68,8 +68,8 @@ tc_refresh_disk_state() { fi tc_log "disk-state refresh: share state ready" - tc_log "disk-state refresh: applying ATA idle settings after payload and share-state build" - tc_configure_ata_idle_for_mast_disks "$volumes_file" || true + tc_log "disk-state refresh: applying ATA drive settings after payload and share-state build" + tc_configure_ata_drive_settings_for_mast_disks "$volumes_file" || true tc_write_payload_state "$TC_RESOLVED_PAYLOAD_DIR" "$TC_RESOLVED_PAYLOAD_VOLUME" "$TC_RESOLVED_PAYLOAD_DEVICE" "$payload_file" mv -f "$volumes_file" "$TC_TOPOLOGY_SIGNATURE" diff --git a/src/timecapsulesmb/cli/configure.py b/src/timecapsulesmb/cli/configure.py index e3f1b216..194e0965 100644 --- a/src/timecapsulesmb/cli/configure.py +++ b/src/timecapsulesmb/cli/configure.py @@ -61,6 +61,16 @@ ] +def non_negative_integer_arg(value: str) -> str: + if not value.isdigit(): + raise argparse.ArgumentTypeError("must be a non-negative integer") + return str(int(value)) + + +def existing_config_value_or_default(existing: dict[str, str], key: str, label: str) -> str: + return valid_existing_config_value(existing, key, label) or DEFAULTS[key] + + def prompt(label: str, default: str, secret: bool) -> str: suffix = f" [{color_cyan(default)}]" if default and not secret else "" text = f"{label}{suffix}: " @@ -298,6 +308,8 @@ 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) + parser.add_argument("--ata-idle-seconds", type=non_negative_integer_arg, metavar="SECONDS", help=argparse.SUPPRESS) + parser.add_argument("--ata-standby", type=non_negative_integer_arg, metavar="SECONDS", help=argparse.SUPPRESS) add_bonjour_timeout_argument(parser) args = parser.parse_args(argv) @@ -365,6 +377,20 @@ def main(argv: Optional[list[str]] = None) -> int: values["TC_DEBUG_LOGGING"] = ( "true" if args.debug_logging or existing_debug_logging else "false" ) + existing_ata_idle_seconds = existing_config_value_or_default( + existing, + "TC_ATA_IDLE_SECONDS", + "ATA idle seconds", + ) + values["TC_ATA_IDLE_SECONDS"] = ( + args.ata_idle_seconds if args.ata_idle_seconds is not None else existing_ata_idle_seconds + ) + existing_ata_standby = existing_config_value_or_default( + existing, + "TC_ATA_STANDBY", + "ATA standby timer", + ) + values["TC_ATA_STANDBY"] = args.ata_standby if args.ata_standby is not None else existing_ata_standby command_context.set_stage("bonjour_discovery") try: discovered_record = discover_default_record(existing, timeout=args.bonjour_timeout) diff --git a/src/timecapsulesmb/cli/deploy.py b/src/timecapsulesmb/cli/deploy.py index 2e2717b3..b77ed68b 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -217,6 +217,7 @@ def main(argv: Optional[list[str]] = None) -> int: print("Deleting old deployed files...") command_context.set_stage("pre_upload_actions") run_remote_actions(connection, plan.pre_upload_actions) + print("Deploying runtime files...") command_context.set_stage("prepare_deployment_files") flash_config_text = render_flash_runtime_config( config, diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index e5e5aed3..b269ed0c 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -78,6 +78,8 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_INTERNAL_SHARE_USE_DISK_ROOT": "false", "TC_ANY_PROTOCOL": "false", "TC_DEBUG_LOGGING": "false", + "TC_ATA_IDLE_SECONDS": "300", + "TC_ATA_STANDBY": "", } ENV_FILE_KEYS = [ @@ -87,6 +89,8 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", "TC_DEBUG_LOGGING", + "TC_ATA_IDLE_SECONDS", + "TC_ATA_STANDBY", "TC_CONFIGURE_ID", ] ENV_FILE_OMIT_KEYS = frozenset({ @@ -481,6 +485,14 @@ def validate_bool(value: str, field_name: str) -> Optional[str]: return None +def validate_optional_unsigned_integer(value: str, field_name: str) -> Optional[str]: + if value == "": + return None + if not value.isdigit(): + return f"{field_name} must be a non-negative integer." + return None + + def validate_airport_syap(value: str, field_name: str) -> Optional[str]: if not value: return f"{field_name} cannot be blank." @@ -514,6 +526,8 @@ def validate_mdns_device_model_matches_syap(syap: str, device_model: str) -> Opt "TC_INTERNAL_SHARE_USE_DISK_ROOT": validate_bool, "TC_ANY_PROTOCOL": validate_bool, "TC_DEBUG_LOGGING": validate_bool, + "TC_ATA_IDLE_SECONDS": validate_optional_unsigned_integer, + "TC_ATA_STANDBY": validate_optional_unsigned_integer, } @@ -530,12 +544,16 @@ class ConfigProfile: "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", "TC_DEBUG_LOGGING", + "TC_ATA_IDLE_SECONDS", + "TC_ATA_STANDBY", ) MANAGED_VALIDATED_KEYS = ( "TC_HOST", "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", "TC_DEBUG_LOGGING", + "TC_ATA_IDLE_SECONDS", + "TC_ATA_STANDBY", ) MANAGED_REQUIRED_FILE_KEYS = ( "TC_HOST", diff --git a/src/timecapsulesmb/services/configure.py b/src/timecapsulesmb/services/configure.py index db652613..65750430 100644 --- a/src/timecapsulesmb/services/configure.py +++ b/src/timecapsulesmb/services/configure.py @@ -1,8 +1,22 @@ from __future__ import annotations +from timecapsulesmb.configure_defaults import valid_existing_config_value from timecapsulesmb.core.config import DEFAULTS, parse_bool, preserved_env_file_values +def _optional_unsigned_config_value(value: str, key: str) -> str: + raw_value = value.strip() + if raw_value == "": + return "" + if not raw_value.isdigit(): + raise ValueError(f"{key} must be a non-negative integer") + return str(int(raw_value)) + + +def _existing_unsigned_config_value_or_default(existing: dict[str, str], key: str, label: str) -> str: + return valid_existing_config_value(existing, key, label) or DEFAULTS[key] + + def build_configure_env_values( existing: dict[str, str], *, @@ -13,6 +27,8 @@ def build_configure_env_values( internal_share_use_disk_root: bool | None = None, any_protocol: bool | None = None, debug_logging: bool | None = None, + ata_idle_seconds: str | None = None, + ata_standby: str | None = None, ) -> dict[str, str]: values = preserved_env_file_values(existing) values.update({ @@ -34,6 +50,16 @@ def build_configure_env_values( if debug_logging is None else debug_logging ) else "false", + "TC_ATA_IDLE_SECONDS": ( + _existing_unsigned_config_value_or_default(existing, "TC_ATA_IDLE_SECONDS", "ATA idle seconds") + if ata_idle_seconds is None + else _optional_unsigned_config_value(ata_idle_seconds, "TC_ATA_IDLE_SECONDS") + ), + "TC_ATA_STANDBY": ( + _existing_unsigned_config_value_or_default(existing, "TC_ATA_STANDBY", "ATA standby timer") + if ata_standby is None + else _optional_unsigned_config_value(ata_standby, "TC_ATA_STANDBY") + ), "TC_CONFIGURE_ID": configure_id, }) return values diff --git a/src/timecapsulesmb/services/deploy.py b/src/timecapsulesmb/services/deploy.py index 985b9db7..eff03fe2 100644 --- a/src/timecapsulesmb/services/deploy.py +++ b/src/timecapsulesmb/services/deploy.py @@ -2,7 +2,7 @@ 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.deploy.planner import DEFAULT_DISKD_USE_VOLUME_ATTEMPTS from timecapsulesmb.device.storage import PayloadHome, PayloadVerificationResult @@ -34,6 +34,30 @@ def _render_flash_config_assignment(key: str, value: str | int) -> str: return f"{key}={shell_quote(value)}" +def _runtime_unsigned_config_value(config: AppConfig, key: str, default: str) -> str: + raw_value = config.get(key, default).strip() + if raw_value == "": + raw_value = default + if raw_value == "": + return "" + if not raw_value.isdigit(): + raise ValueError(f"{key} must be a non-negative integer") + return str(int(raw_value)) + + +def _runtime_unsigned_override_value(value: str | int) -> str | int: + if isinstance(value, int): + if value < 0: + raise ValueError("runtime setting override must be a non-negative integer") + return value + raw_value = value.strip() + if raw_value == "": + return "" + if not raw_value.isdigit(): + raise ValueError("runtime setting override must be a non-negative integer") + return str(int(raw_value)) + + def render_flash_runtime_config( config: AppConfig, payload_home: PayloadHome, @@ -42,12 +66,23 @@ def render_flash_runtime_config( debug_logging: bool | None = None, internal_share_use_disk_root: bool | None = None, any_protocol: bool | None = None, - ata_idle_seconds: int = DEFAULT_ATA_IDLE_SECONDS, + ata_idle_seconds: str | int | None = None, + ata_standby: str | int | None = None, diskd_use_volume_attempts: int = DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, ) -> str: internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) configured_debug_logging = config.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"]) + runtime_ata_idle_seconds = ( + _runtime_unsigned_config_value(config, "TC_ATA_IDLE_SECONDS", DEFAULTS["TC_ATA_IDLE_SECONDS"]) + if ata_idle_seconds is None + else _runtime_unsigned_override_value(ata_idle_seconds) + ) + runtime_ata_standby = ( + _runtime_unsigned_config_value(config, "TC_ATA_STANDBY", DEFAULTS["TC_ATA_STANDBY"]) + if ata_standby is None + else _runtime_unsigned_override_value(ata_standby) + ) effective_internal_root = ( parse_bool(internal_root_default) if internal_share_use_disk_root is None @@ -67,7 +102,8 @@ def render_flash_runtime_config( ("INTERNAL_SHARE_USE_DISK_ROOT", 1 if effective_internal_root else 0), ("ANY_PROTOCOL", 1 if effective_any_protocol else 0), ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), - ("ATA_IDLE_SECONDS", ata_idle_seconds), + ("ATA_IDLE_SECONDS", runtime_ata_idle_seconds), + ("ATA_STANDBY", runtime_ata_standby), ("NBNS_ENABLED", 1 if nbns_enabled else 0), ("SMBD_DEBUG_LOGGING", 1 if effective_debug_logging else 0), ("MDNS_DEBUG_LOGGING", 1 if effective_debug_logging else 0), diff --git a/tests/test_cli.py b/tests/test_cli.py index 3387837e..8c108bfc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1522,6 +1522,8 @@ def test_configure_writes_values_from_prompts(self) -> None: 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") + self.assertEqual(fake_values["TC_ATA_IDLE_SECONDS"], "300") + self.assertEqual(fake_values["TC_ATA_STANDBY"], "") 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"]) @@ -1611,6 +1613,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_hidden_ata_args_write_drive_settings(self) -> None: + result = self.run_configure_cli( + ["--ata-idle-seconds", "0", "--ata-standby", "0"], + 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_ATA_IDLE_SECONDS"], "0") + self.assertEqual(result.values["TC_ATA_STANDBY"], "0") + def test_configure_bonjour_timeout_reaches_discovery(self) -> None: result = self.run_configure_cli( ["--bonjour-timeout", "1.25"], @@ -1634,6 +1648,42 @@ def test_configure_preserves_existing_debug_logging_when_arg_is_omitted(self) -> self.assertEqual(result.rc, 0) self.assertEqual(result.values["TC_DEBUG_LOGGING"], "true") + def test_configure_preserves_existing_ata_settings(self) -> None: + result = self.run_configure_cli( + [], + existing_values={ + "TC_ATA_IDLE_SECONDS": "42", + "TC_ATA_STANDBY": "0", + }, + 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_ATA_IDLE_SECONDS"], "42") + self.assertEqual(result.values["TC_ATA_STANDBY"], "0") + + def test_configure_saves_default_ata_settings_when_existing_env_lacks_them(self) -> None: + result = self.run_configure_cli( + [], + existing_values={ + "TC_HOST": "root@10.0.0.2", + "TC_PASSWORD": "pw", + }, + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + rendered = render_env_text(result.values) + + self.assertEqual(result.rc, 0) + self.assertEqual(result.values["TC_ATA_IDLE_SECONDS"], DEFAULTS["TC_ATA_IDLE_SECONDS"]) + self.assertEqual(result.values["TC_ATA_STANDBY"], DEFAULTS["TC_ATA_STANDBY"]) + self.assertIn("TC_ATA_IDLE_SECONDS=300", rendered) + self.assertIn("TC_ATA_STANDBY=''", rendered) + def test_configure_airport_extreme_keeps_hidden_internal_share_root_default(self) -> None: def fake_prompt(label, default, _secret): if label == "Device SSH target": diff --git a/tests/test_config.py b/tests/test_config.py index ddf3643c..22fc2b15 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -149,6 +149,8 @@ def test_render_env_text_contains_config_keys(self) -> None: self.assertIn("TC_INTERNAL_SHARE_USE_DISK_ROOT=false", rendered) self.assertIn("TC_ANY_PROTOCOL=false", rendered) self.assertIn("TC_DEBUG_LOGGING=false", rendered) + self.assertIn("TC_ATA_IDLE_SECONDS=300", rendered) + self.assertIn("TC_ATA_STANDBY=''", rendered) self.assertIn("TC_CONFIGURE_ID=12345678-1234-1234-1234-123456789012", rendered) def test_render_env_text_preserves_custom_settings_but_omits_deprecated_keys(self) -> None: @@ -530,6 +532,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_DEBUG_LOGGING") + values["TC_DEBUG_LOGGING"] = "false" + values["TC_ATA_IDLE_SECONDS"] = "-1" + config = AppConfig.from_values(values, file_values=values) + errors = validate_app_config(config, profile="deploy") + self.assertEqual(errors[0].kind, "invalid_value") + self.assertEqual(errors[0].key, "TC_ATA_IDLE_SECONDS") def test_flash_profile_ignores_deploy_only_settings(self) -> None: values = dict(DEFAULTS) @@ -543,6 +551,8 @@ def test_flash_profile_ignores_deploy_only_settings(self) -> None: values["TC_INTERNAL_SHARE_USE_DISK_ROOT"] = "not-bool" values["TC_ANY_PROTOCOL"] = "not-bool" values["TC_DEBUG_LOGGING"] = "not-bool" + values["TC_ATA_IDLE_SECONDS"] = "bad" + values["TC_ATA_STANDBY"] = "bad" config = AppConfig.from_values(values, file_values=values) self.assertEqual(validate_app_config(config, profile="flash"), []) diff --git a/tests/test_storage_runtime.py b/tests/test_storage_runtime.py index 3ad73ca5..1ac407bc 100644 --- a/tests/test_storage_runtime.py +++ b/tests/test_storage_runtime.py @@ -108,6 +108,7 @@ def write_runtime_harness(self, tmp_path: Path, *, hostname_output: str | None = ANY_PROTOCOL=0 DISKD_USE_VOLUME_ATTEMPTS=2 ATA_IDLE_SECONDS=300 + ATA_STANDBY='' NBNS_ENABLED=0 SMBD_DEBUG_LOGGING=0 MDNS_DEBUG_LOGGING=0 @@ -889,6 +890,7 @@ def test_flash_runtime_config_contains_runtime_settings_and_no_share_name(self) self.assertIn("ANY_PROTOCOL=1\n", rendered) self.assertIn("DISKD_USE_VOLUME_ATTEMPTS=2\n", rendered) self.assertIn("ATA_IDLE_SECONDS=300\n", rendered) + self.assertIn("ATA_STANDBY=''\n", rendered) self.assertIn("NBNS_ENABLED=1\n", rendered) self.assertIn("SMBD_DEBUG_LOGGING=1\n", rendered) self.assertNotIn("SMB_NETBIOS_NAME", rendered) @@ -962,6 +964,24 @@ def test_flash_runtime_config_deploy_time_overrides_can_disable_saved_values(sel self.assertIn("INTERNAL_SHARE_USE_DISK_ROOT=0\n", rendered) self.assertIn("ANY_PROTOCOL=0\n", rendered) + def test_flash_runtime_config_uses_drive_settings_from_config(self) -> None: + config = AppConfig.from_values( + { + "TC_ATA_IDLE_SECONDS": "0", + "TC_ATA_STANDBY": "0", + } + ) + + rendered = render_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=False, + debug_logging=False, + ) + + self.assertIn("ATA_IDLE_SECONDS=0\n", rendered) + self.assertIn("ATA_STANDBY=0\n", rendered) + def test_common_runtime_identity_normalizers_match_python(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) @@ -2018,7 +2038,7 @@ def test_common_refresh_disk_state_mast_failure_keeps_existing_runtime_state(sel self.assertIn("payload=old-payload\n", proc.stdout) self.assertIn("MaSt discovery failed", proc.stdout) - def test_common_refresh_disk_state_sets_ata_idle_after_share_state(self) -> None: + def test_common_refresh_disk_state_sets_ata_drive_settings_after_share_state(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) flash, _memory, _locks, volumes = self.write_runtime_harness(tmp_path) @@ -2050,7 +2070,7 @@ def test_common_refresh_disk_state_sets_ata_idle_after_share_state(self) -> None : >"$3" return 0 }} - tc_configure_ata_idle_for_mast_disks() {{ echo ata >>{events}; }} + tc_configure_ata_drive_settings_for_mast_disks() {{ echo ata >>{events}; }} tc_resolve_payload() {{ echo payload >>{events} TC_RESOLVED_PAYLOAD_DIR={payload} @@ -4439,9 +4459,9 @@ def test_common_ata_idle_tunes_only_builtin_wd_disks_once(self) -> None: self.assertEqual(proc.returncode, 0, proc.stderr) self.assertEqual(proc.stdout, "wd0 setidle 300\n") - self.assertIn("ATA idle tuning: set wd0 idle timer to 300s", log_text) - self.assertIn("ATA idle tuning: skipping sd0 for /dev/dk4; MaSt marks disk as external", log_text) - self.assertIn("ATA idle tuning: skipping sd1 for /dev/dk5; not a wd ATA disk", log_text) + self.assertIn("ATA drive settings: set wd0 idle timer to 300s", log_text) + self.assertIn("ATA drive settings: skipping sd0 for /dev/dk4; MaSt marks disk as external", log_text) + self.assertIn("ATA drive settings: skipping sd1 for /dev/dk5; not a wd ATA disk", log_text) def test_common_ata_idle_zero_disables_tuning(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -4470,7 +4490,47 @@ def test_common_ata_idle_zero_disables_tuning(self) -> None: wd0 1 dk2 {volumes}/dk2 Data aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa EOF tc_configure_ata_idle_for_mast_disks "$RAM_VAR/test-volumes.tsv" - [ ! -f {atactl_log} ] + cat {atactl_log} + """ + ) + ) + script.chmod(0o755) + + proc = subprocess.run([str(script)], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + log_text = (memory / "samba4/var/test.log").read_text() + + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, "wd0 setidle 0\n") + self.assertIn("ATA drive settings: disabled wd0 idle timer", log_text) + + def test_common_ata_standby_applies_when_configured(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + flash, memory, _locks, volumes = self.write_runtime_harness(tmp_path) + atactl_log = tmp_path / "atactl.log" + atactl = tmp_path / "atactl" + atactl.write_text(f"#!/bin/sh\necho \"$@\" >>{shlex.quote(str(atactl_log))}\n") + atactl.chmod(0o755) + common_path = flash / "common.sh" + common_path.write_text(common_path.read_text().replace("/sbin/atactl", str(atactl))) + script = tmp_path / "ata-standby.sh" + script.write_text( + textwrap.dedent( + f"""\ + #!/bin/sh + set -eu + . {flash}/common.sh + . {flash}/tcapsulesmb.conf + ATA_STANDBY=0 + tc_init_runtime_env + tc_set_log "$RAM_VAR/test.log" test + mkdir -p "$RAM_VAR" + is_volume_root_mounted() {{ return 0; }} + cat >"$RAM_VAR/test-volumes.tsv" <<'EOF' + wd0 1 dk2 {volumes}/dk2 Data aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + EOF + tc_configure_ata_drive_settings_for_mast_disks "$RAM_VAR/test-volumes.tsv" + cat {atactl_log} """ ) ) @@ -4480,7 +4540,8 @@ def test_common_ata_idle_zero_disables_tuning(self) -> None: log_text = (memory / "samba4/var/test.log").read_text() self.assertEqual(proc.returncode, 0, proc.stderr) - self.assertIn("ATA idle tuning disabled", log_text) + self.assertEqual(proc.stdout, "wd0 setidle 300\nwd0 setstandby 0\n") + self.assertIn("ATA drive settings: disabled wd0 standby timer", log_text) def test_common_ata_idle_failure_logs_and_continues(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -4518,7 +4579,7 @@ def test_common_ata_idle_failure_logs_and_continues(self) -> None: self.assertEqual(proc.returncode, 0, proc.stderr) self.assertEqual(proc.stdout, "continued\n") - self.assertIn("ATA idle tuning: failed to set wd0 idle timer to 300s", log_text) + self.assertIn("ATA drive settings: failed to set wd0 idle timer to 300s", log_text) def test_common_wake_or_mount_uses_diskd_without_mount_hfs_fallback_when_it_mounts(self) -> None: with tempfile.TemporaryDirectory() as tmp: From 231539f6da99185d271d74e6b38eeebca808c809 Mon Sep 17 00:00:00 2001 From: James Chang Date: Fri, 22 May 2026 03:52:55 -0700 Subject: [PATCH 032/129] Add TC_ATA_IDLE_SECONDS and TC_ATA_STANDBY to GUI --- .../Backend/OperationParams.swift | 33 +++++++- .../Profiles/DeviceProfile.swift | 52 +++++++++++- .../Profiles/DeviceProfileEditorStore.swift | 60 ++++++++++++-- .../Resources/en.lproj/Localizable.strings | 6 ++ .../Views/Dashboard/SettingsTab.swift | 53 ++++++++---- .../Workflows/DeployWorkflowStore.swift | 76 ++++++++++++++++- .../Workflows/DeviceDashboardSession.swift | 2 + .../Workflows/InstallPresentation.swift | 2 +- .../Workflows/OperationTimeline.swift | 2 + .../DashboardStoreTests.swift | 10 ++- .../DeployWorkflowStoreTests.swift | 26 ++++++ .../DeviceProfileEditorStoreTests.swift | 42 +++++++++- .../DeviceProfileTests.swift | 36 ++++++++ .../OperationTimelineBuilderTests.swift | 8 ++ .../PendingConfirmationTests.swift | 15 +++- src/timecapsulesmb/app/ops/configure.py | 4 +- src/timecapsulesmb/app/ops/deploy.py | 20 ++++- src/timecapsulesmb/core/config.py | 11 ++- src/timecapsulesmb/services/configure.py | 22 ++++- tests/test_app_api.py | 83 +++++++++++++++++++ tests/test_config.py | 5 ++ 21 files changed, 523 insertions(+), 45 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift index 7e1067e2..460909ac 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift @@ -38,7 +38,10 @@ enum OperationParams { password: String, debugLogging: Bool, internalShareUseDiskRoot: Bool? = nil, - anyProtocol: Bool? = nil + anyProtocol: Bool? = nil, + ataIdleSeconds: Int? = nil, + ataStandby: Int? = nil, + includeAtaStandby: Bool = false ) -> [String: JSONValue] { var params: [String: JSONValue] = [ "password": .string(password), @@ -56,6 +59,14 @@ enum OperationParams { if let anyProtocol { params["any_protocol"] = .bool(anyProtocol) } + if let ataIdleSeconds { + params["ata_idle_seconds"] = .number(Double(ataIdleSeconds)) + } + if let ataStandby { + params["ata_standby"] = .number(Double(ataStandby)) + } else if includeAtaStandby { + params["ata_standby"] = .string("") + } return params } @@ -81,10 +92,12 @@ enum OperationParams { internalShareUseDiskRoot: Bool = false, anyProtocol: Bool = false, debugLogging: Bool, + ataIdleSeconds: Int, + ataStandby: Int?, mountWait: Double, password: String ) -> [String: JSONValue] { - let params: [String: JSONValue] = [ + var params: [String: JSONValue] = [ "dry_run": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), @@ -94,6 +107,12 @@ enum OperationParams { "debug_logging": .bool(debugLogging), "mount_wait": .number(mountWait) ] + params["ata_idle_seconds"] = .number(Double(ataIdleSeconds)) + if let ataStandby { + params["ata_standby"] = .number(Double(ataStandby)) + } else { + params["ata_standby"] = .string("") + } return withCredentials(params, password: password) } @@ -104,10 +123,12 @@ enum OperationParams { internalShareUseDiskRoot: Bool = false, anyProtocol: Bool = false, debugLogging: Bool, + ataIdleSeconds: Int, + ataStandby: Int?, mountWait: Double, password: String ) -> [String: JSONValue] { - let params: [String: JSONValue] = [ + var params: [String: JSONValue] = [ "dry_run": .bool(false), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), @@ -117,6 +138,12 @@ enum OperationParams { "debug_logging": .bool(debugLogging), "mount_wait": .number(mountWait) ] + params["ata_idle_seconds"] = .number(Double(ataIdleSeconds)) + if let ataStandby { + params["ata_standby"] = .number(Double(ataStandby)) + } else { + params["ata_standby"] = .string("") + } return withCredentials(params, password: password) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift index 8464fa00..4068315c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift @@ -39,13 +39,17 @@ struct DeviceProfileSettings: Codable, Equatable { var anyProtocol: Bool var debugLogging: Bool var mountWaitSeconds: Int + var ataIdleSeconds: Int + var ataStandby: Int? static let `default` = DeviceProfileSettings( nbnsEnabled: true, internalShareUseDiskRoot: false, anyProtocol: false, debugLogging: false, - mountWaitSeconds: 30 + mountWaitSeconds: 30, + ataIdleSeconds: 300, + ataStandby: nil ) init( @@ -53,13 +57,17 @@ struct DeviceProfileSettings: Codable, Equatable { internalShareUseDiskRoot: Bool = false, anyProtocol: Bool = false, debugLogging: Bool, - mountWaitSeconds: Int + mountWaitSeconds: Int, + ataIdleSeconds: Int = 300, + ataStandby: Int? = nil ) { self.nbnsEnabled = nbnsEnabled self.internalShareUseDiskRoot = internalShareUseDiskRoot self.anyProtocol = anyProtocol self.debugLogging = debugLogging self.mountWaitSeconds = mountWaitSeconds + self.ataIdleSeconds = ataIdleSeconds + self.ataStandby = ataStandby } private enum CodingKeys: String, CodingKey { @@ -68,6 +76,8 @@ struct DeviceProfileSettings: Codable, Equatable { case anyProtocol case debugLogging case mountWaitSeconds + case ataIdleSeconds + case ataStandby } init(from decoder: Decoder) throws { @@ -77,6 +87,44 @@ struct DeviceProfileSettings: Codable, Equatable { anyProtocol = try container.decodeIfPresent(Bool.self, forKey: .anyProtocol) ?? Self.default.anyProtocol debugLogging = try container.decodeIfPresent(Bool.self, forKey: .debugLogging) ?? Self.default.debugLogging mountWaitSeconds = try container.decodeIfPresent(Int.self, forKey: .mountWaitSeconds) ?? Self.default.mountWaitSeconds + ataIdleSeconds = Self.decodeNonNegativeInteger( + from: container, + forKey: .ataIdleSeconds, + defaultValue: Self.default.ataIdleSeconds + ) + ataStandby = Self.decodeOptionalNonNegativeInteger(from: container, forKey: .ataStandby) + } + + private static func decodeNonNegativeInteger( + from container: KeyedDecodingContainer, + forKey key: CodingKeys, + defaultValue: Int + ) -> Int { + if let value = try? container.decodeIfPresent(Int.self, forKey: key), value >= 0 { + return value + } + if let text = try? container.decodeIfPresent(String.self, forKey: key), + let parsed = ValueParsers.nonNegativeInteger(text) { + return parsed + } + return defaultValue + } + + private static func decodeOptionalNonNegativeInteger( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) -> Int? { + if let value = try? container.decodeIfPresent(Int.self, forKey: key), value >= 0 { + return value + } + if let text = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return ValueParsers.nonNegativeInteger(trimmed) + } + return nil } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift index 11079f2c..000fa1d3 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift @@ -40,6 +40,8 @@ enum DeviceProfileEditorValidationError: String, CaseIterable, Equatable, Locali case hostRequired case duplicateHost case mountWaitInvalid + case ataIdleSecondsInvalid + case ataStandbyInvalid case passwordRequired var errorDescription: String? { @@ -50,6 +52,10 @@ enum DeviceProfileEditorValidationError: String, CaseIterable, Equatable, Locali return L10n.string("profile_editor.error.duplicate_host") case .mountWaitInvalid: return L10n.string("profile_editor.error.mount_wait_invalid") + case .ataIdleSecondsInvalid: + return L10n.string("profile_editor.error.ata_idle_seconds_invalid") + case .ataStandbyInvalid: + return L10n.string("profile_editor.error.ata_standby_invalid") case .passwordRequired: return L10n.string("profile_editor.error.password_required") } @@ -64,6 +70,8 @@ struct DeviceProfileEditorDraft: Equatable { var anyProtocol: Bool var debugLogging: Bool var mountWaitSeconds: String + var ataIdleSeconds: String + var ataStandby: String init( displayName: String, @@ -72,7 +80,9 @@ struct DeviceProfileEditorDraft: Equatable { internalShareUseDiskRoot: Bool = false, anyProtocol: Bool = false, debugLogging: Bool, - mountWaitSeconds: String + mountWaitSeconds: String, + ataIdleSeconds: String = String(DeviceProfileSettings.default.ataIdleSeconds), + ataStandby: String = DeviceProfileSettings.default.ataStandby.map { String($0) } ?? "" ) { self.displayName = displayName self.host = host @@ -81,6 +91,8 @@ struct DeviceProfileEditorDraft: Equatable { self.anyProtocol = anyProtocol self.debugLogging = debugLogging self.mountWaitSeconds = mountWaitSeconds + self.ataIdleSeconds = ataIdleSeconds + self.ataStandby = ataStandby } init(profile: DeviceProfile) { @@ -91,7 +103,9 @@ struct DeviceProfileEditorDraft: Equatable { internalShareUseDiskRoot: profile.settings.internalShareUseDiskRoot, anyProtocol: profile.settings.anyProtocol, debugLogging: profile.settings.debugLogging, - mountWaitSeconds: String(profile.settings.mountWaitSeconds) + mountWaitSeconds: String(profile.settings.mountWaitSeconds), + ataIdleSeconds: String(profile.settings.ataIdleSeconds), + ataStandby: profile.settings.ataStandby.map { String($0) } ?? "" ) } @@ -107,12 +121,26 @@ struct DeviceProfileEditorDraft: Equatable { guard let mountWait = ValueParsers.nonNegativeInteger(mountWaitSeconds) else { throw DeviceProfileEditorValidationError.mountWaitInvalid } + guard let ataIdle = ValueParsers.nonNegativeInteger(ataIdleSeconds) else { + throw DeviceProfileEditorValidationError.ataIdleSecondsInvalid + } + let trimmedAtaStandby = ataStandby.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedAtaStandby: String + if trimmedAtaStandby.isEmpty { + normalizedAtaStandby = "" + } else if let parsedAtaStandby = ValueParsers.nonNegativeInteger(trimmedAtaStandby) { + normalizedAtaStandby = String(parsedAtaStandby) + } else { + throw DeviceProfileEditorValidationError.ataStandbyInvalid + } return DeviceProfileSettings( nbnsEnabled: nbnsEnabled, internalShareUseDiskRoot: internalShareUseDiskRoot, anyProtocol: anyProtocol, debugLogging: debugLogging, - mountWaitSeconds: mountWait + mountWaitSeconds: mountWait, + ataIdleSeconds: ataIdle, + ataStandby: normalizedAtaStandby.isEmpty ? nil : ValueParsers.nonNegativeInteger(normalizedAtaStandby) ) } @@ -216,6 +244,16 @@ final class DeviceProfileEditorStore: ObservableObject { return } + let settings: DeviceProfileSettings + do { + settings = try draft.validatedSettings() + } catch { + self.validationErrors = validationErrors + self.error = nil + self.state = .invalid + return + } + if draft.hostChanged(from: profile) { guard let password = appStore.password(for: profile) else { self.validationErrors = [.passwordRequired] @@ -223,7 +261,7 @@ final class DeviceProfileEditorStore: ObservableObject { state = .invalid return } - startReconfigure(profile: profile, password: password) + startReconfigure(profile: profile, password: password, settings: settings) } else { await saveRegistryOnly(profile: profile) } @@ -250,6 +288,13 @@ final class DeviceProfileEditorStore: ObservableObject { if ValueParsers.nonNegativeInteger(draft.mountWaitSeconds) == nil { errors.append(.mountWaitInvalid) } + if ValueParsers.nonNegativeInteger(draft.ataIdleSeconds) == nil { + errors.append(.ataIdleSecondsInvalid) + } + let trimmedAtaStandby = draft.ataStandby.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedAtaStandby.isEmpty && ValueParsers.nonNegativeInteger(trimmedAtaStandby) == nil { + errors.append(.ataStandbyInvalid) + } return errors } @@ -270,13 +315,16 @@ final class DeviceProfileEditorStore: ObservableObject { } } - private func startReconfigure(profile: DeviceProfile, password: String) { + private func startReconfigure(profile: DeviceProfile, password: String, settings: DeviceProfileSettings) { let params = OperationParams.configure( host: draft.trimmedHost, password: password, debugLogging: draft.debugLogging, internalShareUseDiskRoot: draft.internalShareUseDiskRoot, - anyProtocol: draft.anyProtocol + anyProtocol: draft.anyProtocol, + ataIdleSeconds: settings.ataIdleSeconds, + ataStandby: settings.ataStandby, + includeAtaStandby: true ) let start = coordinator.run( operation: "configure", diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 8c7cb10e..1562b0f0 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -267,6 +267,8 @@ "event.summary.result.failed" = "failed"; "event.summary.result.finished" = "finished"; "event.summary.stage" = "%@: %@"; +"field.ata_idle_seconds" = "ATA idle seconds"; +"field.ata_standby" = "ATA standby seconds"; "field.bonjour_timeout" = "Bonjour timeout seconds"; "field.fsck_volume" = "fsck volume, optional"; "field.helper" = "Helper"; @@ -411,9 +413,12 @@ "password.error.missing" = "Password is missing."; "password.error.required" = "Password is required."; "password.error.unreadable_keychain_item" = "Keychain returned an unreadable password."; +"profile_editor.advanced" = "Advanced"; "profile_editor.display_name" = "Display Name"; "profile_editor.error.duplicate_host" = "Another saved Time Capsule already uses this host."; "profile_editor.error.host_required" = "Host is required."; +"profile_editor.error.ata_idle_seconds_invalid" = "ATA idle seconds must be a non-negative integer."; +"profile_editor.error.ata_standby_invalid" = "ATA standby seconds must be blank or a non-negative integer."; "profile_editor.error.mount_wait_invalid" = "Mount wait must be a non-negative integer."; "profile_editor.error.password_required" = "A saved password is required to change the host."; "profile_editor.reset" = "Reset"; @@ -488,6 +493,7 @@ "timeline.stage.checking_bundled_files" = "Checking Bundled Files"; "timeline.stage.checking_runtime" = "Checking SMB"; "timeline.stage.checking_ssh" = "Checking SSH"; +"timeline.stage.deleting_old_deployed_files" = "Deleting Old Deployed Files"; "timeline.stage.enabling_ssh" = "Enabling SSH"; "timeline.stage.finding_disk" = "Finding Disk"; "timeline.stage.finding_time_capsules" = "Finding Time Capsules"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift index 2611594a..785f4c46 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift @@ -42,22 +42,10 @@ private struct DeviceProfileEditorView: View { TextField(L10n.string("dashboard.overview.host"), text: $store.draft.host) .frame(maxWidth: 360) } - GridRow { - Text(L10n.string("field.mount_wait")) - .foregroundStyle(.secondary) - TextField(L10n.string("field.mount_wait"), text: $store.draft.mountWaitSeconds) - .frame(width: 160) - } - GridRow { - Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) - Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $store.draft.internalShareUseDiskRoot) - } - GridRow { - Toggle(L10n.string("toggle.any_protocol"), isOn: $store.draft.anyProtocol) - Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) - } } + DeviceProfileAdvancedSettingsView(store: store) + HStack { Button { Task { @MainActor in @@ -101,3 +89,40 @@ private struct DeviceProfileEditorView: View { .padding(.bottom, 8) } } + +private struct DeviceProfileAdvancedSettingsView: View { + @ObservedObject var store: DeviceProfileEditorStore + + var body: some View { + DashboardDisclosureSection(title: L10n.string("profile_editor.advanced")) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.draft.mountWaitSeconds) + .frame(width: 160) + } + GridRow { + Text(L10n.string("field.ata_idle_seconds")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.ata_idle_seconds"), text: $store.draft.ataIdleSeconds) + .frame(width: 160) + } + GridRow { + Text(L10n.string("field.ata_standby")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.ata_standby"), text: $store.draft.ataStandby) + .frame(width: 160) + } + GridRow { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) + Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $store.draft.internalShareUseDiskRoot) + } + GridRow { + Toggle(L10n.string("toggle.any_protocol"), isOn: $store.draft.anyProtocol) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) + } + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift index 0ae416bf..0fd87d0e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift @@ -8,7 +8,31 @@ struct DeployOptions: Equatable { let internalShareUseDiskRoot: Bool let anyProtocol: Bool let debugLogging: Bool + let ataIdleSeconds: Int + let ataStandby: Int? let mountWait: Int + + init( + nbnsEnabled: Bool, + noReboot: Bool, + noWait: Bool, + internalShareUseDiskRoot: Bool, + anyProtocol: Bool, + debugLogging: Bool, + ataIdleSeconds: Int = DeviceProfileSettings.default.ataIdleSeconds, + ataStandby: Int? = DeviceProfileSettings.default.ataStandby, + mountWait: Int + ) { + self.nbnsEnabled = nbnsEnabled + self.noReboot = noReboot + self.noWait = noWait + self.internalShareUseDiskRoot = internalShareUseDiskRoot + self.anyProtocol = anyProtocol + self.debugLogging = debugLogging + self.ataIdleSeconds = ataIdleSeconds + self.ataStandby = ataStandby + self.mountWait = mountWait + } } enum DeployExecutionOptionPolicy { @@ -93,6 +117,12 @@ final class DeployWorkflowStore: ObservableObject { @Published var debugLogging = false { didSet { reconcilePlanFreshness() } } + @Published var ataIdleSeconds = String(DeviceProfileSettings.default.ataIdleSeconds) { + didSet { reconcilePlanFreshness() } + } + @Published var ataStandby = DeviceProfileSettings.default.ataStandby.map { String($0) } ?? "" { + didSet { reconcilePlanFreshness() } + } @Published var mountWait = "30" { didSet { reconcilePlanFreshness() } } @@ -166,6 +196,10 @@ final class DeployWorkflowStore: ObservableObject { ValueParsers.nonNegativeInteger(mountWait) } + var hasValidOptions: Bool { + deployOptionsValidationMessage == nil + } + var canDeploy: Bool { !isBusy && state == .planReady && plan != nil && currentOptions == plannedOptions } @@ -173,8 +207,9 @@ final class DeployWorkflowStore: ObservableObject { @discardableResult func runPlan(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { guard let options = currentOptions else { - failLocally(state: .planFailed, message: "Mount wait must be a non-negative integer.") - return .rejected("Mount wait must be a non-negative integer.") + let message = deployOptionsValidationMessage ?? "Deploy options are invalid." + failLocally(state: .planFailed, message: message) + return .rejected(message) } guard !isBusy else { rejectRun(state: .planFailed, message: "Another operation is already running.") @@ -190,6 +225,8 @@ final class DeployWorkflowStore: ObservableObject { internalShareUseDiskRoot: options.internalShareUseDiskRoot, anyProtocol: options.anyProtocol, debugLogging: options.debugLogging, + ataIdleSeconds: options.ataIdleSeconds, + ataStandby: options.ataStandby, mountWait: Double(options.mountWait), password: password ), @@ -239,6 +276,8 @@ final class DeployWorkflowStore: ObservableObject { internalShareUseDiskRoot: options.internalShareUseDiskRoot, anyProtocol: options.anyProtocol, debugLogging: options.debugLogging, + ataIdleSeconds: options.ataIdleSeconds, + ataStandby: options.ataStandby, mountWait: Double(options.mountWait), password: password ), @@ -276,7 +315,7 @@ final class DeployWorkflowStore: ObservableObject { } private var currentOptions: DeployOptions? { - guard let mountWaitValue else { + guard let mountWaitValue, let ataIdleSecondsValue, hasValidAtaStandby else { return nil } let rebootOptions = DeployExecutionOptionPolicy.effectiveRebootOptions(noReboot: noReboot, noWait: noWait) @@ -287,10 +326,41 @@ final class DeployWorkflowStore: ObservableObject { internalShareUseDiskRoot: internalShareUseDiskRoot, anyProtocol: anyProtocol, debugLogging: debugLogging, + ataIdleSeconds: ataIdleSecondsValue, + ataStandby: ataStandbyValue, mountWait: mountWaitValue ) } + private var ataIdleSecondsValue: Int? { + ValueParsers.nonNegativeInteger(ataIdleSeconds) + } + + private var ataStandbyValue: Int? { + let trimmed = ataStandby.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return ValueParsers.nonNegativeInteger(trimmed) + } + + private var hasValidAtaStandby: Bool { + ataStandby.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || ataStandbyValue != nil + } + + private var deployOptionsValidationMessage: String? { + if mountWaitValue == nil { + return "Mount wait must be a non-negative integer." + } + if ataIdleSecondsValue == nil { + return L10n.string("profile_editor.error.ata_idle_seconds_invalid") + } + if !hasValidAtaStandby { + return L10n.string("profile_editor.error.ata_standby_invalid") + } + return nil + } + private func reconcilePlanFreshness() { guard plan != nil, state == .planReady || state == .planStale else { return diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index 13d29519..c2afe594 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -284,6 +284,8 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { deployStore.internalShareUseDiskRoot = settings.internalShareUseDiskRoot deployStore.anyProtocol = settings.anyProtocol deployStore.debugLogging = settings.debugLogging + deployStore.ataIdleSeconds = String(settings.ataIdleSeconds) + deployStore.ataStandby = settings.ataStandby.map { String($0) } ?? "" deployStore.mountWait = String(settings.mountWaitSeconds) maintenanceStore.mountWait = String(settings.mountWaitSeconds) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift index d35defc1..372642ba 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift @@ -166,7 +166,7 @@ enum InstallActionAvailabilityPolicy { static func isEnabled(_ action: InstallUserAction, store: DeployWorkflowStore) -> Bool { switch action { case .createPlan, .regeneratePlan, .reinstall: - return !store.isBusy && store.mountWaitValue != nil + return !store.isBusy && store.hasValidOptions case .installUpdate: return store.canDeploy case .runCheckup: diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift index 4cf7f460..b34bf430 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift @@ -117,6 +117,8 @@ enum OperationTimelineBuilder { return L10n.string("timeline.stage.checking_bundled_files") case ("deploy", "read_mast"), ("deploy", "select_payload_home"): return L10n.string("timeline.stage.finding_disk") + case ("deploy", "pre_upload_actions"): + return L10n.string("timeline.stage.deleting_old_deployed_files") case ("deploy", "upload_payload"): return L10n.string("timeline.stage.uploading") case ("deploy", "flush_payload_upload"): diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index 0d7a7fcc..b8deb918 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -216,7 +216,9 @@ final class DashboardStoreTests: XCTestCase { internalShareUseDiskRoot: true, anyProtocol: true, debugLogging: true, - mountWaitSeconds: 45 + mountWaitSeconds: 45, + ataIdleSeconds: 0, + ataStandby: 0 ) profile = try await fixture.registry.updateProfile(profile) let dashboard = DashboardStore(appStore: fixture.appStore) @@ -226,6 +228,8 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(session.deployStore.internalShareUseDiskRoot, true) XCTAssertEqual(session.deployStore.anyProtocol, true) XCTAssertEqual(session.deployStore.debugLogging, true) + XCTAssertEqual(session.deployStore.ataIdleSeconds, "0") + XCTAssertEqual(session.deployStore.ataStandby, "0") XCTAssertEqual(session.deployStore.mountWait, "45") XCTAssertEqual(session.maintenanceStore.mountWait, "45") @@ -258,6 +262,8 @@ final class DashboardStoreTests: XCTestCase { session.profileEditorStore.draft.anyProtocol = true session.profileEditorStore.draft.debugLogging = true session.profileEditorStore.draft.mountWaitSeconds = "64" + session.profileEditorStore.draft.ataIdleSeconds = "0" + session.profileEditorStore.draft.ataStandby = "0" await session.profileEditorStore.save(profile: profile) @@ -266,6 +272,8 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(session.deployStore.internalShareUseDiskRoot, true) XCTAssertEqual(session.deployStore.anyProtocol, true) XCTAssertEqual(session.deployStore.debugLogging, true) + XCTAssertEqual(session.deployStore.ataIdleSeconds, "0") + XCTAssertEqual(session.deployStore.ataStandby, "0") XCTAssertEqual(session.deployStore.mountWait, "64") XCTAssertEqual(session.maintenanceStore.mountWait, "64") } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift index e733ab2b..46cc7faa 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift @@ -43,6 +43,8 @@ final class DeployWorkflowStoreTests: XCTestCase { store.internalShareUseDiskRoot = true store.anyProtocol = true store.debugLogging = true + store.ataIdleSeconds = "0" + store.ataStandby = "0" store.runPlan(password: "pw") @@ -59,10 +61,34 @@ final class DeployWorkflowStoreTests: XCTestCase { XCTAssertEqual(runner.calls[0].params["internal_share_use_disk_root"], .bool(true)) XCTAssertEqual(runner.calls[0].params["any_protocol"], .bool(true)) XCTAssertEqual(runner.calls[0].params["debug_logging"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["ata_idle_seconds"], .number(0)) + XCTAssertEqual(runner.calls[0].params["ata_standby"], .number(0)) XCTAssertEqual(runner.calls[0].params["mount_wait"], .number(45)) XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) } + func testInvalidAtaOptionsMoveToPlanFailedWithoutRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.ataIdleSeconds = "bad" + store.runPlan(password: "pw") + + XCTAssertEqual(store.state, .planFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(store.error?.message, "ATA idle seconds must be a non-negative integer.") + XCTAssertEqual(runner.calls, []) + + store.ataIdleSeconds = "300" + store.ataStandby = "bad" + store.runPlan(password: "pw") + + XCTAssertEqual(store.state, .planFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(store.error?.message, "ATA standby seconds must be blank or a non-negative integer.") + XCTAssertEqual(runner.calls, []) + } + func testNoRebootAndNoWaitAreMutuallyExclusive() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift index 70dd8a9e..5afd9bcf 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift @@ -19,11 +19,13 @@ final class DeviceProfileEditorStoreTests: XCTestCase { "hostRequired", "duplicateHost", "mountWaitInvalid", + "ataIdleSecondsInvalid", + "ataStandbyInvalid", "passwordRequired" ]) } - func testMountWaitValidationAcceptsZeroAndPositiveIntegersOnly() throws { + func testIntegerSettingValidationAcceptsZeroAndPositiveIntegersOnly() throws { var draft = DeviceProfileEditorDraft( displayName: "Office", host: "10.0.0.2", @@ -42,6 +44,30 @@ final class DeviceProfileEditorStoreTests: XCTestCase { XCTAssertEqual(error as? DeviceProfileEditorValidationError, .mountWaitInvalid) } } + + draft.mountWaitSeconds = "45" + draft.ataIdleSeconds = "0" + XCTAssertEqual(try draft.validatedSettings().ataIdleSeconds, 0) + draft.ataIdleSeconds = "300" + XCTAssertEqual(try draft.validatedSettings().ataIdleSeconds, 300) + for invalid in ["", "-1", "1.5", "abc"] { + draft.ataIdleSeconds = invalid + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? DeviceProfileEditorValidationError, .ataIdleSecondsInvalid) + } + } + + draft.ataIdleSeconds = "300" + draft.ataStandby = "" + XCTAssertNil(try draft.validatedSettings().ataStandby) + draft.ataStandby = "0" + XCTAssertEqual(try draft.validatedSettings().ataStandby, 0) + for invalid in ["-1", "1.5", "abc"] { + draft.ataStandby = invalid + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? DeviceProfileEditorValidationError, .ataStandbyInvalid) + } + } } func testUndoingDraftChangeReturnsEditorToCleanState() async throws { @@ -103,6 +129,8 @@ final class DeviceProfileEditorStoreTests: XCTestCase { store.draft.anyProtocol = true store.draft.debugLogging = true store.draft.mountWaitSeconds = "45" + store.draft.ataIdleSeconds = "0" + store.draft.ataStandby = "0" await store.save(profile: profile) @@ -115,7 +143,9 @@ final class DeviceProfileEditorStoreTests: XCTestCase { internalShareUseDiskRoot: true, anyProtocol: true, debugLogging: true, - mountWaitSeconds: 45 + mountWaitSeconds: 45, + ataIdleSeconds: 0, + ataStandby: 0 )) XCTAssertEqual(fixture.runner.calls, []) } @@ -237,6 +267,8 @@ final class DeviceProfileEditorStoreTests: XCTestCase { store.draft.anyProtocol = true store.draft.debugLogging = true store.draft.mountWaitSeconds = "60" + store.draft.ataIdleSeconds = "0" + store.draft.ataStandby = "0" await store.save(profile: profile) @@ -251,6 +283,8 @@ final class DeviceProfileEditorStoreTests: XCTestCase { XCTAssertEqual(call.params["internal_share_use_disk_root"], .bool(true)) XCTAssertEqual(call.params["any_protocol"], .bool(true)) XCTAssertEqual(call.params["debug_logging"], .bool(true)) + XCTAssertEqual(call.params["ata_idle_seconds"], .number(0)) + XCTAssertEqual(call.params["ata_standby"], .number(0)) let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) XCTAssertEqual(saved.id, profile.id) @@ -264,7 +298,9 @@ final class DeviceProfileEditorStoreTests: XCTestCase { internalShareUseDiskRoot: true, anyProtocol: true, debugLogging: true, - mountWaitSeconds: 60 + mountWaitSeconds: 60, + ataIdleSeconds: 0, + ataStandby: 0 )) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift index 7a607886..3506404b 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift @@ -71,6 +71,42 @@ final class DeviceProfileTests: XCTestCase { XCTAssertEqual(settings.anyProtocol, false) XCTAssertEqual(settings.debugLogging, true) XCTAssertEqual(settings.mountWaitSeconds, 45) + XCTAssertEqual(settings.ataIdleSeconds, 300) + XCTAssertNil(settings.ataStandby) + } + + func testProfileSettingsDecodeLegacyStringAtaValues() throws { + let data = Data(""" + { + "nbnsEnabled": true, + "debugLogging": false, + "mountWaitSeconds": 45, + "ataIdleSeconds": "0", + "ataStandby": "120" + } + """.utf8) + + let settings = try JSONDecoder().decode(DeviceProfileSettings.self, from: data) + + XCTAssertEqual(settings.ataIdleSeconds, 0) + XCTAssertEqual(settings.ataStandby, 120) + } + + func testProfileSettingsInvalidLegacyAtaValuesFallbackSafely() throws { + let data = Data(""" + { + "nbnsEnabled": true, + "debugLogging": false, + "mountWaitSeconds": 45, + "ataIdleSeconds": "bad", + "ataStandby": "bad" + } + """.utf8) + + let settings = try JSONDecoder().decode(DeviceProfileSettings.self, from: data) + + XCTAssertEqual(settings.ataIdleSeconds, 300) + XCTAssertNil(settings.ataStandby) } func testTraitsClassifyNetBSD4NetBSD6AndUnsupportedDevices() { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift index c02e83e4..d6595884 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift @@ -86,4 +86,12 @@ final class OperationTimelineBuilderTests: XCTestCase { XCTAssertEqual(timeline.map(\.title), ["Checking SMB", "Starting SMB", "Verifying SMB"]) } + + func testDeployCleanupStageWarnsAboutOldFileDeletion() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "pre_upload_actions") + ]) + + XCTAssertEqual(timeline.map(\.title), ["Deleting Old Deployed Files"]) + } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index c23d90c6..d8b0c58d 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -27,6 +27,8 @@ final class PendingConfirmationTests: XCTestCase { noWait: true, nbnsEnabled: true, debugLogging: true, + ataIdleSeconds: 0, + ataStandby: 0, mountWait: 45, password: "" ) @@ -38,6 +40,8 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(params["no_reboot"], .bool(false)) XCTAssertEqual(params["nbns_enabled"], .bool(true)) XCTAssertEqual(params["debug_logging"], .bool(true)) + XCTAssertEqual(params["ata_idle_seconds"], .number(0)) + XCTAssertEqual(params["ata_standby"], .number(0)) XCTAssertEqual(params["mount_wait"], .number(45)) XCTAssertEqual(params["no_wait"], .bool(true)) XCTAssertEqual(params["internal_share_use_disk_root"], .bool(false)) @@ -53,6 +57,8 @@ final class PendingConfirmationTests: XCTestCase { internalShareUseDiskRoot: true, anyProtocol: true, debugLogging: false, + ataIdleSeconds: 0, + ataStandby: nil, mountWait: 30, password: "pw" ) @@ -61,6 +67,8 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(params["internal_share_use_disk_root"], .bool(true)) XCTAssertEqual(params["any_protocol"], .bool(true)) XCTAssertEqual(params["debug_logging"], .bool(false)) + XCTAssertEqual(params["ata_idle_seconds"], .number(0)) + XCTAssertEqual(params["ata_standby"], .string("")) XCTAssertEqual(params["credentials"], .object(["password": .string("pw")])) } @@ -78,7 +86,10 @@ final class PendingConfirmationTests: XCTestCase { password: "pw", debugLogging: true, internalShareUseDiskRoot: false, - anyProtocol: true + anyProtocol: true, + ataIdleSeconds: 0, + ataStandby: nil, + includeAtaStandby: true ) XCTAssertNil(params["host"]) @@ -87,6 +98,8 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(params["debug_logging"], .bool(true)) XCTAssertEqual(params["internal_share_use_disk_root"], .bool(false)) XCTAssertEqual(params["any_protocol"], .bool(true)) + XCTAssertEqual(params["ata_idle_seconds"], .number(0)) + XCTAssertEqual(params["ata_standby"], .string("")) } func testConfigureParamsDefaultBareManualHostToRootUser() { diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py index 38a79701..e1fde647 100644 --- a/src/timecapsulesmb/app/ops/configure.py +++ b/src/timecapsulesmb/app/ops/configure.py @@ -77,8 +77,8 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation "debug_logging", parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])), ), - ata_idle_seconds=string_param(params, "ata_idle_seconds") if "ata_idle_seconds" in params else None, - ata_standby=string_param(params, "ata_standby") if "ata_standby" in params else None, + ata_idle_seconds=params.get("ata_idle_seconds") if "ata_idle_seconds" in params else None, + ata_standby=params.get("ata_standby") if "ata_standby" in params else None, ) except ValueError as exc: raise AppOperationError(str(exc), code="validation_failed") from exc diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index 445c0b98..1d50bccc 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -82,7 +82,6 @@ config_path, int_param, optional_bool_param, - string_param, ) from timecapsulesmb.services.credentials import overlay_request_credentials from timecapsulesmb.services.deploy import ( @@ -123,6 +122,15 @@ def effective_no_wait_for_deploy(*, requested: bool, no_reboot: bool) -> bool: return False if no_reboot else requested +def optional_unsigned_int_override_param(params: dict[str, object], name: str) -> int | str | None: + if name not in params or params.get(name) is None: + return None + value = params.get(name) + if isinstance(value, str) and value.strip() == "": + return "" + return int_param(params, name, 0) + + def activation_complete_message(*, is_netbsd4: bool) -> str: if is_netbsd4: return f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}" @@ -233,6 +241,12 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) allow_unsupported = bool_param(params, "allow_unsupported") debug_logging = optional_bool_param(params, "debug_logging") + ata_idle_seconds = ( + int_param(params, "ata_idle_seconds", int(DEFAULTS["TC_ATA_IDLE_SECONDS"])) + if "ata_idle_seconds" in params and params.get("ata_idle_seconds") is not None + else None + ) + ata_standby = optional_unsigned_int_override_param(params, "ata_standby") config, target = load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) connection = target.connection @@ -371,8 +385,8 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes debug_logging=debug_logging, internal_share_use_disk_root=internal_share_use_disk_root, any_protocol=any_protocol, - ata_idle_seconds=string_param(params, "ata_idle_seconds") if "ata_idle_seconds" in params else None, - ata_standby=string_param(params, "ata_standby") if "ata_standby" in params else None, + ata_idle_seconds=ata_idle_seconds, + ata_standby=ata_standby, ) except ValueError as exc: raise AppOperationError(str(exc), code="validation_failed") from exc diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index b269ed0c..90055266 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -493,6 +493,12 @@ def validate_optional_unsigned_integer(value: str, field_name: str) -> Optional[ return None +def validate_unsigned_integer(value: str, field_name: str) -> Optional[str]: + if not value.isdigit(): + return f"{field_name} must be a non-negative integer." + return None + + def validate_airport_syap(value: str, field_name: str) -> Optional[str]: if not value: return f"{field_name} cannot be blank." @@ -526,7 +532,7 @@ def validate_mdns_device_model_matches_syap(syap: str, device_model: str) -> Opt "TC_INTERNAL_SHARE_USE_DISK_ROOT": validate_bool, "TC_ANY_PROTOCOL": validate_bool, "TC_DEBUG_LOGGING": validate_bool, - "TC_ATA_IDLE_SECONDS": validate_optional_unsigned_integer, + "TC_ATA_IDLE_SECONDS": validate_unsigned_integer, "TC_ATA_STANDBY": validate_optional_unsigned_integer, } @@ -649,7 +655,8 @@ def validate_app_config(config: AppConfig, *, profile: str) -> list[ConfigIssue] validator = CONFIG_VALIDATORS.get(key) if validator is None: continue - error = validator(config.get(key, ""), key) + value = config.values[key] if key in config.values else DEFAULTS.get(key, "") + error = validator(value, key) if error: errors.append(ConfigIssue( kind="invalid_value", diff --git a/src/timecapsulesmb/services/configure.py b/src/timecapsulesmb/services/configure.py index 65750430..d09ffc91 100644 --- a/src/timecapsulesmb/services/configure.py +++ b/src/timecapsulesmb/services/configure.py @@ -1,11 +1,25 @@ from __future__ import annotations +import math + from timecapsulesmb.configure_defaults import valid_existing_config_value from timecapsulesmb.core.config import DEFAULTS, parse_bool, preserved_env_file_values -def _optional_unsigned_config_value(value: str, key: str) -> str: - raw_value = value.strip() +def _optional_unsigned_config_value(value: object, key: str) -> str: + if value is None: + return "" + if isinstance(value, bool): + raise ValueError(f"{key} must be a non-negative integer") + if isinstance(value, int): + if value < 0: + raise ValueError(f"{key} must be a non-negative integer") + return str(value) + if isinstance(value, float): + if not math.isfinite(value) or not value.is_integer() or value < 0: + raise ValueError(f"{key} must be a non-negative integer") + return str(int(value)) + raw_value = str(value).strip() if raw_value == "": return "" if not raw_value.isdigit(): @@ -27,8 +41,8 @@ def build_configure_env_values( internal_share_use_disk_root: bool | None = None, any_protocol: bool | None = None, debug_logging: bool | None = None, - ata_idle_seconds: str | None = None, - ata_standby: str | None = None, + ata_idle_seconds: object | None = None, + ata_standby: object | None = None, ) -> dict[str, str]: values = preserved_env_file_values(existing) values.update({ diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 296ad295..b33563cd 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -556,6 +556,8 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s "TC_PASSWORD=oldpw\n" "TC_CUSTOM_SETTING='keep me'\n" "TC_DEBUG_LOGGING=true\n" + "TC_ATA_IDLE_SECONDS=42\n" + "TC_ATA_STANDBY=0\n" "TC_SAMBA_USER=old-admin\n" "TC_PAYLOAD_DIR_NAME=old-payload\n" ) @@ -579,6 +581,8 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s self.assertEqual(values["TC_PASSWORD"], "") self.assertEqual(values["TC_CUSTOM_SETTING"], "keep me") self.assertEqual(values["TC_DEBUG_LOGGING"], "true") + self.assertEqual(values["TC_ATA_IDLE_SECONDS"], "42") + self.assertEqual(values["TC_ATA_STANDBY"], "0") self.assertNotIn("TC_SAMBA_USER", values) self.assertNotIn("TC_PAYLOAD_DIR_NAME", values) @@ -605,6 +609,56 @@ def test_configure_debug_logging_param_writes_true(self) -> None: self.assertEqual(rc, 0) self.assertEqual(values["TC_DEBUG_LOGGING"], "true") + def test_configure_ata_params_write_drive_timer_settings(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "goodpw", + "ata_idle_seconds": 0, + "ata_standby": 0, + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_ATA_IDLE_SECONDS"], "0") + self.assertEqual(values["TC_ATA_STANDBY"], "0") + + def test_configure_blank_ata_standby_clears_existing_timer_setting(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + config_path.write_text("TC_HOST=root@10.0.0.2\nTC_ATA_IDLE_SECONDS=300\nTC_ATA_STANDBY=120\n") + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "goodpw", + "ata_standby": "", + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_ATA_IDLE_SECONDS"], "300") + self.assertEqual(values["TC_ATA_STANDBY"], "") + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: @@ -1141,6 +1195,31 @@ def test_deploy_rejects_boolean_mount_wait_before_remote_connection(self) -> Non self.assertEqual(error["code"], "validation_failed") self.assertIn("mount_wait must be an integer", error["message"]) + def test_deploy_rejects_invalid_ata_overrides_before_remote_connection(self) -> None: + for field, value, expected in ( + ("ata_idle_seconds", "bad", "ata_idle_seconds must be an integer"), + ("ata_standby", "bad", "ata_standby must be an integer"), + ): + with self.subTest(field=field): + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config") as load_config: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": True, + field: value, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + load_config.assert_not_called() + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertIn(expected, error["message"]) + def test_deploy_no_reboot_uploads_and_activates_without_reboot_wait(self) -> None: collector = CollectingSink() connection = SshConnection("root@10.0.0.2", "pw", "-o foo") @@ -1176,6 +1255,8 @@ def test_deploy_no_reboot_uploads_and_activates_without_reboot_wait(self) -> Non "internal_share_use_disk_root": False, "any_protocol": False, "debug_logging": False, + "ata_idle_seconds": 0, + "ata_standby": 0, }, }, collector.sink, @@ -1190,6 +1271,8 @@ def test_deploy_no_reboot_uploads_and_activates_without_reboot_wait(self) -> Non self.assertEqual(render_runtime.call_args.kwargs["internal_share_use_disk_root"], False) self.assertEqual(render_runtime.call_args.kwargs["any_protocol"], False) self.assertEqual(render_runtime.call_args.kwargs["debug_logging"], False) + self.assertEqual(render_runtime.call_args.kwargs["ata_idle_seconds"], 0) + self.assertEqual(render_runtime.call_args.kwargs["ata_standby"], 0) self.assertEqual(collector.events_of_type("result")[0]["payload"]["rebooted"], False) self.assertEqual(collector.events_of_type("result")[0]["payload"]["verified"], True) diff --git a/tests/test_config.py b/tests/test_config.py index 22fc2b15..c2c5fda5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -538,6 +538,11 @@ 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_ATA_IDLE_SECONDS") + values["TC_ATA_IDLE_SECONDS"] = "" + config = AppConfig.from_values(values, file_values=values) + errors = validate_app_config(config, profile="deploy") + self.assertEqual(errors[0].kind, "invalid_value") + self.assertEqual(errors[0].key, "TC_ATA_IDLE_SECONDS") def test_flash_profile_ignores_deploy_only_settings(self) -> None: values = dict(DEFAULTS) From 179062688352859deddd421d933c877ed231f354 Mon Sep 17 00:00:00 2001 From: James Chang Date: Fri, 22 May 2026 05:17:12 -0700 Subject: [PATCH 033/129] Add app-level settings screen --- GUI_ARCH.md | 82 +++- gui.md | 112 +++-- .../TimeCapsuleSMBApp/App/AppSettings.swift | 403 ++++++++++++++++++ .../TimeCapsuleSMBApp/App/AppStore.swift | 65 ++- .../Backend/BackendPayloads.swift | 44 ++ .../Policies/HostCompatibilityPolicy.swift | 8 +- .../Policies/ValueParsers.swift | 8 + .../Resources/en.lproj/Localizable.strings | 33 ++ .../Views/Components/SharedViews.swift | 4 +- .../Views/Dashboard/CheckupTab.swift | 3 +- .../Views/Dashboard/DeviceDashboardView.swift | 14 +- .../Views/Dashboard/InstallTab.swift | 3 +- .../Views/Diagnostics/AppReadinessViews.swift | 7 +- .../Views/Shell/AppSettingsView.swift | 186 ++++++++ .../Views/Shell/ContentView.swift | 27 ++ .../Workflows/ActivityStore.swift | 10 +- .../Workflows/AddDeviceFlowStore.swift | 63 ++- .../Workflows/AppUpdateStore.swift | 141 ++++++ .../Workflows/DeviceDashboardSession.swift | 10 + .../DeviceDiscoveryMonitorStore.swift | 8 +- .../Workflows/DoctorStore.swift | 20 +- .../Workflows/OperationTimeline.swift | 4 + .../AddDeviceFlowStoreTests.swift | 82 ++++ .../AppSettingsStoreTests.swift | 122 ++++++ .../HostCompatibilityPolicyTests.swift | 7 + src/timecapsulesmb/app/contracts.py | 29 ++ src/timecapsulesmb/app/ops/__init__.py | 6 + src/timecapsulesmb/app/ops/readiness.py | 60 ++- src/timecapsulesmb/app/stage_policy.py | 6 + src/timecapsulesmb/cli/version_check.py | 41 +- src/timecapsulesmb/identity.py | 8 + tests/test_app_api.py | 87 ++++ tests/test_identity.py | 17 +- tests/test_version_check.py | 9 + 34 files changed, 1650 insertions(+), 79 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift diff --git a/GUI_ARCH.md b/GUI_ARCH.md index c461abef..58c5e328 100644 --- a/GUI_ARCH.md +++ b/GUI_ARCH.md @@ -31,8 +31,9 @@ and manifests needed by those checks. - 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 execution is coordinated through one global `OperationCoordinator`, + but work is separated into lanes. Each lane has one active helper operation at + a time, while unrelated lanes can run independently. - Backend contracts are typed at the GUI boundary. Swift decodes payloads into models and does not parse human log text for app behavior. - Credentials never persist to `.env`. GUI passwords live in Keychain and are @@ -52,27 +53,39 @@ Target source organization: TimeCapsuleSMBApp/ App/ AppStore.swift - AppReadinessStore.swift + AppCloseGuard.swift + BundleLayout.swift Backend/ BackendClient.swift BackendPayloads.swift HelperLocator.swift HelperRunner.swift - OperationCoordinator.swift OperationParams.swift PendingConfirmation.swift Profiles/ DeviceProfile.swift + DeviceProfileEditorStore.swift DeviceRegistryStore.swift PasswordStore.swift Policies/ + DashboardActionPolicy.swift + DeviceStatusPolicy.swift + DoctorCheckDomainPolicy.swift HostCompatibilityPolicy.swift + RecoveryActionMapper.swift + SMBAddressPolicy.swift Workflows/ + ActivityStore.swift AddDeviceFlowStore.swift + AppReadinessStore.swift DashboardStore.swift DeployWorkflowStore.swift + DeviceDashboardSession.swift + DeviceDiscoveryMonitorStore.swift DoctorStore.swift + FlashWorkflowStore.swift MaintenanceStore.swift + OperationCoordinator.swift Views/ Shell/ AddDevice/ @@ -154,10 +167,20 @@ run(operation:params:profile:password:) run(operation:params:context:activeDeviceID:password:) ``` +The coordinator owns separate lanes: + +- `.app` for app readiness and global discovery +- `.device()` for profile-scoped operations +- `.candidateHost()` for unsaved device setup work +- `.localPath()` for local mounted-share maintenance + Responsibilities: -- reject a second operation while one is running +- reject a second operation in the same lane while that lane is busy +- allow unrelated lanes to run independently when their work cannot corrupt the + same profile or operation state - expose active operation and active profile ID +- expose all active operations for Activity and close-guard behavior - inject password credentials when provided - delegate profile context to `BackendClient` - preserve context through confirmation replay @@ -262,7 +285,7 @@ The dashboard has these user-facing tabs: - Install / Update - Checkup - Maintenance -- Advanced +- Settings Overview is decision-oriented. It shows device identity, password state, host macOS warnings, last checkup, last install/update, and one primary action. @@ -278,10 +301,45 @@ Maintenance wraps: - uninstall - fsck - repair xattrs -- future flash workflow - -Advanced contains raw events, helper path, profile ID, config path, and other -technical diagnostics. +- disabled NetBSD4 flash boot hook scaffold + +Settings contains device-level profile editing: + +- display name +- host/IP +- profile save/reset state +- advanced runtime defaults for deploy/reconfigure: + - mount wait + - ATA idle seconds + - ATA standby seconds + - NBNS enabled + - internal share uses disk root + - allow any SMB protocol + - force debug logging + +Raw events, helper path, readiness validation, profile ID, config path, and other +technical diagnostics belong in Diagnostics or compact Advanced disclosures, not +in the primary workflow controls. + +App-level Settings are a top-level sidebar surface and stay separate from the +device profile editor. They own: + +- defaults for newly added devices +- global Bonjour/checkup discovery timeout defaults +- telemetry preference +- helper path override +- Diagnostics raw-event display default +- update/version check controls +- Time Machine warning policy + +## Close Guard + +The app must route window close and Command-Q through shared close-guard +behavior. If any operation lane has active work or a pending confirmation, the +user gets a native confirmation before the app closes. + +This guard should be based on `OperationCoordinator.hasActiveWork`, not on a +single backend client, because operations can run on multiple lanes. ## App Readiness And Bundling @@ -348,12 +406,14 @@ 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 +- operation rejection while another operation is active on the same lane +- independent app, device, candidate, and local-path lane behavior - add-device discover/manual/auth/unsupported/duplicate/password-save failure - dashboard primary action derivation - operation snapshots attributed to active operation profile ID - host compatibility warning matrix - helper locator production and development environment behavior +- close guard behavior for window close and Command-Q Regression runs: diff --git a/gui.md b/gui.md index 5b035a3d..9cc1d99b 100644 --- a/gui.md +++ b/gui.md @@ -51,16 +51,20 @@ Recommended top-level structure: - Sidebar - All Time Capsules - - Add Time Capsule - Activity - Settings - - Help + - saved device rows + - Add Time Capsule + +Future top-level surfaces: + +- Help - Device detail area - selected device summary - primary action - health and warnings - - workflow tabs or sections + - workflow tabs: Overview, Install / Update, Checkup, Maintenance, Settings - Bottom or collapsible activity drawer - latest operation progress @@ -231,10 +235,16 @@ Suggested layout: - SMB auth - Time Machine +Current implementation status: + +- Overview has Connection, Runtime, and Checkup sections. +- Detailed Finder/Bonjour, SMB auth, Time Machine, disk, and metadata signals + are grouped in the Checkup tab. + - Secondary actions - Maintenance - Uninstall - - Advanced + - Settings The dashboard should run a lightweight refresh when selected. Full doctor can be manual or automatically offered after deploy/update. @@ -399,7 +409,20 @@ Recommended sections: - Disk Repair - File Metadata Repair - Uninstall -- Firmware Flash, disabled or experimental +- Persistent NetBSD4 Boot Hook, disabled in the current build + +Current implementation status: + +- NetBSD4 activation, disk repair, file metadata repair, and uninstall are + implemented as planned workflows with explicit state machines, dry-run plans + where applicable, confirmations, progress timelines, advanced options, and + typed backend payloads. +- Activation is hidden for devices that do not need NetBSD4 post-reboot + activation. +- Successful uninstall clears the saved deploy/install snapshot so the app no + longer presents the device as installed. +- The persistent NetBSD4 boot hook has a NetBSD4-only GUI scaffold, but the + read-only and write workflows are still disabled. ### NetBSD4 Activation @@ -509,8 +532,9 @@ 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. +Flash should remain disabled before release unless it has gone through separate +acceptance testing. The current GUI exposes a NetBSD4-only disabled scaffold for +this area under Maintenance. Product label: @@ -521,10 +545,10 @@ 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 +- visible only for NetBSD4 devices +- disabled in the current build +- read-only backup/analyze should be the first enabled mode +- write actions stay disabled in release builds until explicitly enabled Eligibility checks: @@ -602,37 +626,54 @@ After restore write: ## Settings -App-level settings: +Settings are split by scope. -- default Bonjour timeout -- default mount wait -- diagnostics sharing/telemetry preference -- show advanced options -- check for app updates -- Time Machine warning policy version +Device Settings are the fifth device dashboard tab and contain: -Device-level settings: - -- nickname +- display name - host/IP +- profile save/reset state +- runtime defaults under an Advanced disclosure: + - mount wait + - ATA idle seconds + - ATA standby seconds + - NBNS enabled + - internal share uses disk root + - allow any SMB protocol + - force debug logging + +App-level Settings are a separate top-level sidebar surface and contain: + +- new-device defaults for NBNS, SMB compatibility flags, debug logging, mount + wait, and ATA settings +- default Bonjour/checkup timeout +- telemetry preference +- helper path override +- Diagnostics raw-event display default +- update check on launch, manual update check, and version metadata URL override +- Time Machine warning policy + +Device-level settings still planned or only partially represented: + - stored password status -- NBNS enabled -- debug logging for future deploys -- advanced SSH options, hidden +- replace stored password entry point - forget device +- refresh identity ## Background Jobs -The app should run these without presenting them as commands: +The app already runs 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 +Still planned: + +- lightweight selected-device reachability refresh + If background jobs fail: - app damaged: blocking alert @@ -669,11 +710,16 @@ All Time Capsules Disk Repair File Metadata Repair Uninstall - Firmware Boot Hook (experimental) - Advanced - logs - raw operation events - copy diagnostics + Persistent NetBSD4 Boot Hook (disabled) + Settings + device profile + advanced runtime defaults + +Diagnostics + app readiness + helper path + raw operation events + copy diagnostics Add Time Capsule Discover @@ -688,7 +734,7 @@ Activity historical operations copied diagnostics -Settings +Future App Settings app defaults warning policy updates diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift new file mode 100644 index 00000000..92d56747 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift @@ -0,0 +1,403 @@ +import Combine +import Foundation + +struct AppSettings: Codable, Equatable { + var defaultBonjourTimeoutSeconds: Double + var defaultDeviceSettings: DeviceProfileSettings + var telemetryEnabled: Bool + var helperPathOverride: String + var showRawBackendEventsByDefault: Bool + var checkForUpdatesOnLaunch: Bool + var versionCheckURL: String + var timeMachineWarningsEnabled: Bool + + static let `default` = AppSettings( + defaultBonjourTimeoutSeconds: 6, + defaultDeviceSettings: .default, + telemetryEnabled: true, + helperPathOverride: "", + showRawBackendEventsByDefault: true, + checkForUpdatesOnLaunch: true, + versionCheckURL: "", + timeMachineWarningsEnabled: true + ) + + init( + defaultBonjourTimeoutSeconds: Double, + defaultDeviceSettings: DeviceProfileSettings, + telemetryEnabled: Bool, + helperPathOverride: String, + showRawBackendEventsByDefault: Bool, + checkForUpdatesOnLaunch: Bool, + versionCheckURL: String, + timeMachineWarningsEnabled: Bool + ) { + self.defaultBonjourTimeoutSeconds = defaultBonjourTimeoutSeconds + self.defaultDeviceSettings = defaultDeviceSettings + self.telemetryEnabled = telemetryEnabled + self.helperPathOverride = helperPathOverride + self.showRawBackendEventsByDefault = showRawBackendEventsByDefault + self.checkForUpdatesOnLaunch = checkForUpdatesOnLaunch + self.versionCheckURL = versionCheckURL + self.timeMachineWarningsEnabled = timeMachineWarningsEnabled + } + + private enum CodingKeys: String, CodingKey { + case defaultBonjourTimeoutSeconds + case defaultDeviceSettings + case telemetryEnabled + case helperPathOverride + case showRawBackendEventsByDefault + case checkForUpdatesOnLaunch + case versionCheckURL + case timeMachineWarningsEnabled + } + + init(from decoder: Decoder) throws { + let defaults = Self.default + let container = try decoder.container(keyedBy: CodingKeys.self) + defaultBonjourTimeoutSeconds = Self.decodeNonNegativeDouble( + from: container, + forKey: .defaultBonjourTimeoutSeconds, + defaultValue: defaults.defaultBonjourTimeoutSeconds + ) + defaultDeviceSettings = try container.decodeIfPresent(DeviceProfileSettings.self, forKey: .defaultDeviceSettings) + ?? defaults.defaultDeviceSettings + telemetryEnabled = try container.decodeIfPresent(Bool.self, forKey: .telemetryEnabled) ?? defaults.telemetryEnabled + helperPathOverride = try container.decodeIfPresent(String.self, forKey: .helperPathOverride) ?? defaults.helperPathOverride + showRawBackendEventsByDefault = try container.decodeIfPresent(Bool.self, forKey: .showRawBackendEventsByDefault) + ?? defaults.showRawBackendEventsByDefault + checkForUpdatesOnLaunch = try container.decodeIfPresent(Bool.self, forKey: .checkForUpdatesOnLaunch) + ?? defaults.checkForUpdatesOnLaunch + versionCheckURL = try container.decodeIfPresent(String.self, forKey: .versionCheckURL) ?? defaults.versionCheckURL + timeMachineWarningsEnabled = try container.decodeIfPresent(Bool.self, forKey: .timeMachineWarningsEnabled) + ?? defaults.timeMachineWarningsEnabled + } + + private static func decodeNonNegativeDouble( + from container: KeyedDecodingContainer, + forKey key: CodingKeys, + defaultValue: Double + ) -> Double { + guard let value = try? container.decodeIfPresent(Double.self, forKey: key), + value.isFinite, + value >= 0 + else { + return defaultValue + } + return value + } +} + +enum AppSettingsValidationError: Equatable, LocalizedError { + case invalidBonjourTimeout + case invalidMountWait + case invalidAtaIdleSeconds + case invalidAtaStandby + case invalidVersionCheckURL + + var errorDescription: String? { + switch self { + case .invalidBonjourTimeout: + return L10n.string("app_settings.error.bonjour_timeout") + case .invalidMountWait: + return L10n.string("app_settings.error.mount_wait") + case .invalidAtaIdleSeconds: + return L10n.string("app_settings.error.ata_idle") + case .invalidAtaStandby: + return L10n.string("app_settings.error.ata_standby") + case .invalidVersionCheckURL: + return L10n.string("app_settings.error.version_url") + } + } +} + +enum AppSettingsState: String, Equatable { + case idle + case loading + case loaded + case saving + case failed +} + +enum AppSettingsStoreError: Equatable, LocalizedError { + case corruptSettings(String) + case io(String) + + var errorDescription: String? { + switch self { + case .corruptSettings(let message): + return L10n.format("app_settings.error.corrupt", message) + case .io(let message): + return message + } + } +} + +@MainActor +final class AppSettingsStore: ObservableObject { + @Published private(set) var state: AppSettingsState = .idle + @Published private(set) var settings: AppSettings = .default + @Published private(set) var error: AppSettingsStoreError? + + let settingsURL: URL + + private let repository: AppSettingsRepository + + convenience init() { + let appSupport = BundleLayout.applicationSupportDirectory() ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/TimeCapsuleSMB", isDirectory: true) + self.init(settingsURL: appSupport.appendingPathComponent("app-settings.json")) + } + + init(settingsURL: URL, fileManager: FileManager = .default) { + self.settingsURL = settingsURL + self.repository = AppSettingsRepository(settingsURL: settingsURL, fileManager: fileManager) + } + + func load() async { + state = .loading + error = nil + do { + settings = try await repository.load() + state = .loaded + } catch { + fail(error) + } + } + + func save(_ nextSettings: AppSettings) async throws { + state = .saving + error = nil + do { + try await repository.save(nextSettings) + settings = nextSettings + state = .loaded + } catch { + fail(error) + throw error + } + } + + func reset() async throws { + try await save(.default) + } + + private func fail(_ error: Error) { + if let appSettingsError = error as? AppSettingsStoreError { + self.error = appSettingsError + } else { + self.error = .io(error.localizedDescription) + } + state = .failed + } +} + +private actor AppSettingsRepository { + private let settingsURL: URL + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(settingsURL: URL, fileManager: FileManager) { + self.settingsURL = settingsURL + self.fileManager = fileManager + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder = encoder + self.decoder = JSONDecoder() + } + + func load() throws -> AppSettings { + guard fileManager.fileExists(atPath: settingsURL.path) else { + return .default + } + do { + let data = try Data(contentsOf: settingsURL) + return try decoder.decode(AppSettings.self, from: data) + } catch let decoding as DecodingError { + throw AppSettingsStoreError.corruptSettings(String(describing: decoding)) + } catch let settingsError as AppSettingsStoreError { + throw settingsError + } catch { + throw AppSettingsStoreError.io(error.localizedDescription) + } + } + + func save(_ settings: AppSettings) throws { + do { + try fileManager.createDirectory( + at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try encoder.encode(settings) + try data.write(to: settingsURL, options: [.atomic]) + } catch { + throw AppSettingsStoreError.io(error.localizedDescription) + } + } +} + +struct AppSettingsDraft: Equatable { + var defaultBonjourTimeoutSeconds: String + var nbnsEnabled: Bool + var internalShareUseDiskRoot: Bool + var anyProtocol: Bool + var debugLogging: Bool + var mountWaitSeconds: String + var ataIdleSeconds: String + var ataStandby: String + var telemetryEnabled: Bool + var helperPathOverride: String + var showRawBackendEventsByDefault: Bool + var checkForUpdatesOnLaunch: Bool + var versionCheckURL: String + var timeMachineWarningsEnabled: Bool + + init(settings: AppSettings) { + defaultBonjourTimeoutSeconds = Self.formatDouble(settings.defaultBonjourTimeoutSeconds) + nbnsEnabled = settings.defaultDeviceSettings.nbnsEnabled + internalShareUseDiskRoot = settings.defaultDeviceSettings.internalShareUseDiskRoot + anyProtocol = settings.defaultDeviceSettings.anyProtocol + debugLogging = settings.defaultDeviceSettings.debugLogging + mountWaitSeconds = String(settings.defaultDeviceSettings.mountWaitSeconds) + ataIdleSeconds = String(settings.defaultDeviceSettings.ataIdleSeconds) + ataStandby = settings.defaultDeviceSettings.ataStandby.map(String.init) ?? "" + telemetryEnabled = settings.telemetryEnabled + helperPathOverride = settings.helperPathOverride + showRawBackendEventsByDefault = settings.showRawBackendEventsByDefault + checkForUpdatesOnLaunch = settings.checkForUpdatesOnLaunch + versionCheckURL = settings.versionCheckURL + timeMachineWarningsEnabled = settings.timeMachineWarningsEnabled + } + + func validatedSettings() throws -> AppSettings { + guard let bonjourTimeout = ValueParsers.nonNegativeDouble(defaultBonjourTimeoutSeconds) else { + throw AppSettingsValidationError.invalidBonjourTimeout + } + guard let mountWait = ValueParsers.nonNegativeInteger(mountWaitSeconds) else { + throw AppSettingsValidationError.invalidMountWait + } + guard let ataIdle = ValueParsers.nonNegativeInteger(ataIdleSeconds) else { + throw AppSettingsValidationError.invalidAtaIdleSeconds + } + let trimmedAtaStandby = ataStandby.trimmingCharacters(in: .whitespacesAndNewlines) + let parsedAtaStandby: Int? + if trimmedAtaStandby.isEmpty { + parsedAtaStandby = nil + } else if let value = ValueParsers.nonNegativeInteger(trimmedAtaStandby) { + parsedAtaStandby = value + } else { + throw AppSettingsValidationError.invalidAtaStandby + } + + let trimmedVersionURL = versionCheckURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedVersionURL.isEmpty, !Self.isHTTPURL(trimmedVersionURL) { + throw AppSettingsValidationError.invalidVersionCheckURL + } + + return AppSettings( + defaultBonjourTimeoutSeconds: bonjourTimeout, + defaultDeviceSettings: DeviceProfileSettings( + nbnsEnabled: nbnsEnabled, + internalShareUseDiskRoot: internalShareUseDiskRoot, + anyProtocol: anyProtocol, + debugLogging: debugLogging, + mountWaitSeconds: mountWait, + ataIdleSeconds: ataIdle, + ataStandby: parsedAtaStandby + ), + telemetryEnabled: telemetryEnabled, + helperPathOverride: helperPathOverride.trimmingCharacters(in: .whitespacesAndNewlines), + showRawBackendEventsByDefault: showRawBackendEventsByDefault, + checkForUpdatesOnLaunch: checkForUpdatesOnLaunch, + versionCheckURL: trimmedVersionURL, + timeMachineWarningsEnabled: timeMachineWarningsEnabled + ) + } + + private static func formatDouble(_ value: Double) -> String { + guard value.rounded() == value else { + return String(value) + } + return String(Int(value)) + } + + private static func isHTTPURL(_ text: String) -> Bool { + guard let url = URL(string: text), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + url.host != nil + else { + return false + } + return true + } +} + +@MainActor +final class AppSettingsEditorStore: ObservableObject { + @Published var draft: AppSettingsDraft + @Published private(set) var baseline: AppSettings + @Published private(set) var isSaving = false + @Published private(set) var errorMessage: String? + + init(settings: AppSettings = .default) { + self.baseline = settings + self.draft = AppSettingsDraft(settings: settings) + } + + var hasChanges: Bool { + guard let settings = try? draft.validatedSettings() else { + return true + } + return settings != baseline + } + + var validationError: String? { + do { + _ = try draft.validatedSettings() + return nil + } catch { + return error.localizedDescription + } + } + + var canSave: Bool { + validationError == nil && hasChanges && !isSaving + } + + func sync(settings: AppSettings) { + guard !isSaving else { + return + } + baseline = settings + draft = AppSettingsDraft(settings: settings) + errorMessage = nil + } + + func resetDraft() { + draft = AppSettingsDraft(settings: baseline) + errorMessage = nil + } + + func restoreDefaultsDraft() { + draft = AppSettingsDraft(settings: .default) + errorMessage = nil + } + + func save(appStore: AppStore) async { + do { + let settings = try draft.validatedSettings() + isSaving = true + errorMessage = nil + try await appStore.saveAppSettings(settings) + baseline = settings + draft = AppSettingsDraft(settings: settings) + } catch { + errorMessage = error.localizedDescription + } + isSaving = false + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift index db61aa81..74473c8a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift @@ -6,8 +6,11 @@ final class AppStore: ObservableObject { @Published var selectedDeviceID: DeviceProfile.ID? @Published var showingAddDevice = false @Published var showingActivity = false + @Published var showingAppSettings = false let appReadinessStore: AppReadinessStore + let appSettingsStore: AppSettingsStore + let appUpdateStore: AppUpdateStore let deviceRegistry: DeviceRegistryStore let operationCoordinator: OperationCoordinator let passwordStore: PasswordStore @@ -20,6 +23,7 @@ final class AppStore: ObservableObject { let coordinator = OperationCoordinator() self.init( appReadinessStore: AppReadinessStore(backend: coordinator.appLane.backend), + appSettingsStore: AppSettingsStore(), deviceRegistry: DeviceRegistryStore(), operationCoordinator: coordinator, passwordStore: KeychainPasswordStore(), @@ -29,17 +33,21 @@ final class AppStore: ObservableObject { init( appReadinessStore: AppReadinessStore, + appSettingsStore: AppSettingsStore? = nil, deviceRegistry: DeviceRegistryStore, operationCoordinator: OperationCoordinator, passwordStore: PasswordStore, activityStore: ActivityStore? = nil, + appUpdateStore: AppUpdateStore? = nil, discoveryMonitor: DeviceDiscoveryMonitorStore? = nil ) { self.appReadinessStore = appReadinessStore + self.appSettingsStore = appSettingsStore ?? AppSettingsStore() self.deviceRegistry = deviceRegistry self.operationCoordinator = operationCoordinator self.passwordStore = passwordStore self.activityStore = activityStore ?? ActivityStore(coordinator: operationCoordinator) + self.appUpdateStore = appUpdateStore ?? AppUpdateStore(coordinator: operationCoordinator) self.discoveryMonitor = discoveryMonitor ?? DeviceDiscoveryMonitorStore( coordinator: operationCoordinator, readinessStore: appReadinessStore, @@ -51,6 +59,16 @@ final class AppStore: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) + self.appSettingsStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + self.appUpdateStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) deviceRegistry.objectWillChange .sink { [weak self] _ in self?.objectWillChange.send() @@ -89,28 +107,43 @@ final class AppStore: ObservableObject { } func start() async { + await appSettingsStore.load() + applyAppSettings(appSettingsStore.settings) await deviceRegistry.load() await refreshPasswordStates() appReadinessStore.start() discoveryMonitor.startMonitoring() + if appSettingsStore.settings.checkForUpdatesOnLaunch { + appUpdateStore.checkNow(settings: appSettingsStore.settings) + } } func select(_ profile: DeviceProfile) { selectedDeviceID = profile.id showingAddDevice = false showingActivity = false + showingAppSettings = false } func showAddDevice() { selectedDeviceID = nil showingAddDevice = true showingActivity = false + showingAppSettings = false } func showActivity() { selectedDeviceID = nil showingAddDevice = false showingActivity = true + showingAppSettings = false + } + + func showAppSettings() { + selectedDeviceID = nil + showingAddDevice = false + showingActivity = false + showingAppSettings = true } func dashboardSummary(for profile: DeviceProfile) -> DeviceDashboardSummary { @@ -131,10 +164,22 @@ final class AppStore: ObservableObject { passwordState: passwordState, displayStatus: displayStatus, primaryAction: primaryAction, - hostWarning: HostCompatibilityPolicy.warning() + hostWarning: HostCompatibilityPolicy.warning(enabled: appSettingsStore.settings.timeMachineWarningsEnabled) ) } + func saveAppSettings(_ settings: AppSettings) async throws { + let previousSettings = appSettingsStore.settings + try await appSettingsStore.save(settings) + applyAppSettings(settings) + if previousSettings.telemetryEnabled != settings.telemetryEnabled { + syncTelemetryPreference(settings.telemetryEnabled) + } + if previousSettings.helperPathOverride != settings.helperPathOverride { + appReadinessStore.start() + } + } + func password(for profile: DeviceProfile) -> String? { if profile.passwordState == .invalid { return nil @@ -170,6 +215,7 @@ final class AppStore: ObservableObject { selectedDeviceID = deviceRegistry.profiles.first?.id showingAddDevice = false showingActivity = false + showingAppSettings = false } } @@ -186,6 +232,22 @@ final class AppStore: ObservableObject { return passwordStore.state(for: profile.keychainAccount) } + private func applyAppSettings(_ settings: AppSettings) { + if backend.helperPath != settings.helperPathOverride { + backend.helperPath = settings.helperPathOverride + } + discoveryMonitor.applyAppSettings(settings) + } + + private func syncTelemetryPreference(_ enabled: Bool) { + let params: [String: JSONValue] = ["enabled": .bool(enabled)] + _ = operationCoordinator.run( + operation: "set-telemetry", + params: params, + laneKey: .localPath("app-settings") + ) + } + private func syncSelection(profiles: [DeviceProfile]) { if let selectedDeviceID, profiles.contains(where: { $0.id == selectedDeviceID }) { return @@ -194,6 +256,7 @@ final class AppStore: ObservableObject { if !profiles.isEmpty { showingAddDevice = false showingActivity = false + showingAppSettings = false } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift index e816bb70..260536b5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift @@ -82,6 +82,50 @@ struct InstallValidationPayload: Decodable, Equatable { } } +struct TelemetryIdentityPayload: Decodable, Equatable { + let schemaVersion: Int + let installId: String? + let telemetryEnabled: Bool + let bootstrapPath: String + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case installId = "install_id" + case telemetryEnabled = "telemetry_enabled" + case bootstrapPath = "bootstrap_path" + case summary + } +} + +struct VersionCheckPayload: Decodable, Equatable { + let schemaVersion: Int + let shouldBlock: Bool + let checkedURL: String + let message: String + let downloadURL: String + let localVersionCode: Int + let currentVersion: Int? + let minSupportedVersion: Int? + let latestTag: String? + let source: String + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case shouldBlock = "should_block" + case checkedURL = "checked_url" + case message + case downloadURL = "download_url" + case localVersionCode = "local_version_code" + case currentVersion = "current_version" + case minSupportedVersion = "min_supported_version" + case latestTag = "latest_tag" + case source + case summary + } +} + struct InstallCheckPayload: Decodable, Equatable { let id: String let ok: Bool diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/HostCompatibilityPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/HostCompatibilityPolicy.swift index 5c96dea1..56e4f44c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/HostCompatibilityPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/HostCompatibilityPolicy.swift @@ -28,7 +28,13 @@ enum HostCompatibilityPolicy { KnownHostCompatibilityIssue(majorVersion: 26, minorVersion: 4, patchVersions: nil) ] - static func warning(for version: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion) -> HostCompatibilityWarning? { + static func warning( + enabled: Bool = true, + for version: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion + ) -> HostCompatibilityWarning? { + guard enabled else { + return nil + } guard knownTimeMachineIssues.contains(where: { $0.matches(version) }) else { return nil } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/ValueParsers.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/ValueParsers.swift index b8b32903..cc0a5062 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/ValueParsers.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/ValueParsers.swift @@ -8,4 +8,12 @@ enum ValueParsers { } return value } + + static func nonNegativeDouble(_ text: String) -> Double? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value >= 0 else { + return nil + } + return value + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 1562b0f0..ae60b3bd 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -57,6 +57,36 @@ "app_readiness.recovery.helper_missing" = "Reinstall TimeCapsuleSMB or choose a valid helper in Diagnostics."; "app_readiness.recovery.install_validation_failed" = "Reinstall TimeCapsuleSMB or open Diagnostics for the failed checks."; "app_readiness.recovery.retry_diagnostics" = "Open Diagnostics and retry app readiness."; +"app_settings.blank_uses_device_default" = "Blank"; +"app_settings.check_now" = "Check Now"; +"app_settings.check_updates_on_launch" = "Check for updates on launch"; +"app_settings.default_bonjour_timeout" = "Bonjour timeout seconds"; +"app_settings.error.ata_idle" = "ATA idle seconds must be a non-negative integer."; +"app_settings.error.ata_standby" = "ATA standby seconds must be blank or a non-negative integer."; +"app_settings.error.bonjour_timeout" = "Bonjour timeout must be a non-negative number."; +"app_settings.error.corrupt" = "App settings could not be read: %@"; +"app_settings.error.mount_wait" = "Mount wait must be a non-negative integer."; +"app_settings.error.version_url" = "Version check URL must be blank or an HTTP/HTTPS URL."; +"app_settings.helper_path" = "Helper path"; +"app_settings.restore_defaults" = "Restore Defaults"; +"app_settings.reset_saved" = "Reset to Saved"; +"app_settings.save" = "Save Settings"; +"app_settings.section.defaults" = "New Device Defaults"; +"app_settings.section.diagnostics" = "Helper and Diagnostics"; +"app_settings.section.privacy" = "Privacy"; +"app_settings.section.time_machine" = "Time Machine"; +"app_settings.section.updates" = "Updates"; +"app_settings.show_raw_events" = "Show raw backend events in Diagnostics"; +"app_settings.subtitle" = "Defaults for new devices and app-level behavior."; +"app_settings.telemetry_enabled" = "Share anonymous CLI telemetry"; +"app_settings.time_machine_warnings" = "Show macOS Time Machine compatibility warnings"; +"app_settings.title" = "Settings"; +"app_settings.version_url" = "Version metadata URL"; +"app_update.state.checking" = "Checking for updates"; +"app_update.state.current" = "TimeCapsuleSMB is up to date."; +"app_update.state.failed" = "Update check failed."; +"app_update.state.idle" = "Not checked yet."; +"app_update.state.update_available" = "Update available."; "button.activate" = "Activate"; "button.capabilities" = "Capabilities"; "button.configure" = "Configure"; @@ -460,6 +490,7 @@ "sidebar.all_time_capsules" = "All Time Capsules"; "sidebar.activity" = "Activity"; "sidebar.devices" = "Devices"; +"sidebar.settings" = "Settings"; "status.activation_needed" = "Activation Needed"; "status.checking" = "Checking"; "status.failed" = "Failed"; @@ -487,7 +518,9 @@ "timeline.operation.fsck" = "Disk Repair"; "timeline.operation.readiness" = "App Readiness"; "timeline.operation.repair_xattrs" = "File Metadata Repair"; +"timeline.operation.telemetry" = "Telemetry Settings"; "timeline.operation.uninstall" = "Uninstall"; +"timeline.operation.version_check" = "Update Check"; "timeline.result.done" = "Done"; "timeline.result.failed" = "Failed"; "timeline.stage.checking_bundled_files" = "Checking Bundled Files"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift index 1e952360..5d5e6a6d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift @@ -15,7 +15,9 @@ struct WarningBanner: View { .foregroundStyle(.secondary) } } - .padding(10) + .padding(.vertical, 10) + .padding(.leading, 14) + .padding(.trailing, 18) .background(Color.yellow.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 6)) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift index cd58ad79..0379ee28 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift @@ -3,6 +3,7 @@ import SwiftUI struct CheckupTab: View { let profile: DeviceProfile @ObservedObject var session: DeviceDashboardSession + let appSettings: AppSettings let showDiagnostics: () -> Void var body: some View { @@ -12,7 +13,7 @@ struct CheckupTab: View { state: store.state, events: store.events, currentStage: store.currentStage, - hostWarning: HostCompatibilityPolicy.warning() + hostWarning: HostCompatibilityPolicy.warning(enabled: appSettings.timeMachineWarningsEnabled) ) let progress = CheckupProgressPresentation(state: store.state, currentStage: store.currentStage) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift index dde4daa6..c2cb215c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift @@ -23,9 +23,19 @@ struct DeviceDashboardView: View { case .overview: OverviewTab(profile: profile, session: session, appStore: appStore) case .install: - InstallTab(profile: profile, session: session, showDiagnostics: showDiagnostics) + InstallTab( + profile: profile, + session: session, + appSettings: appStore.appSettingsStore.settings, + showDiagnostics: showDiagnostics + ) case .checkup: - CheckupTab(profile: profile, session: session, showDiagnostics: showDiagnostics) + CheckupTab( + profile: profile, + session: session, + appSettings: appStore.appSettingsStore.settings, + showDiagnostics: showDiagnostics + ) case .maintenance: MaintenanceTab(profile: profile, session: session, showDiagnostics: showDiagnostics) case .settings: diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift index a3e35210..9b6f9bb5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift @@ -3,6 +3,7 @@ import SwiftUI struct InstallTab: View { let profile: DeviceProfile @ObservedObject var session: DeviceDashboardSession + let appSettings: AppSettings let showDiagnostics: () -> Void var body: some View { @@ -17,7 +18,7 @@ struct InstallTab: View { currentStage: store.currentStage, plannedOptions: store.plannedOptions, profile: profile, - hostWarning: HostCompatibilityPolicy.warning(), + hostWarning: HostCompatibilityPolicy.warning(enabled: appSettings.timeMachineWarningsEnabled), isCheckupRunning: summary.displayStatus == .checking ) let progress = InstallProgressPresentation(state: store.state, currentStage: store.currentStage) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift index 0aaf009e..b5203eae 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift @@ -92,6 +92,7 @@ struct AppReadinessBlockedView: View { struct AppDiagnosticsView: View { @ObservedObject var store: AppReadinessStore let events: [BackendEvent] + @Binding var showBackendEvents: Bool @Binding var helperPath: String @Environment(\.dismiss) private var dismiss @@ -150,9 +151,11 @@ struct AppDiagnosticsView: View { } } - Text(L10n.string("diagnostics.backend_events")) + Toggle(L10n.string("diagnostics.backend_events"), isOn: $showBackendEvents) .font(.headline) - EventList(events: events) + if showBackendEvents { + EventList(events: events) + } } .padding() .frame(minWidth: 720, minHeight: 520) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift new file mode 100644 index 00000000..ed694884 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift @@ -0,0 +1,186 @@ +import SwiftUI + +struct AppSettingsView: View { + @ObservedObject var appStore: AppStore + @ObservedObject var editor: AppSettingsEditorStore + + private let contentWidth: CGFloat = 760 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + header + .frame(maxWidth: contentWidth, alignment: .leading) + + SettingsFormSection(title: L10n.string("app_settings.section.defaults"), contentWidth: contentWidth) { + SettingsFormRow(title: L10n.string("app_settings.default_bonjour_timeout")) { + TextField("", text: $editor.draft.defaultBonjourTimeoutSeconds) + .frame(width: 120) + } + Toggle(L10n.string("toggle.enable_nbns"), isOn: $editor.draft.nbnsEnabled) + Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $editor.draft.internalShareUseDiskRoot) + Toggle(L10n.string("toggle.any_protocol"), isOn: $editor.draft.anyProtocol) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $editor.draft.debugLogging) + SettingsFormRow(title: L10n.string("field.mount_wait")) { + TextField("", text: $editor.draft.mountWaitSeconds) + .frame(width: 120) + } + SettingsFormRow(title: L10n.string("field.ata_idle_seconds")) { + TextField("", text: $editor.draft.ataIdleSeconds) + .frame(width: 120) + } + SettingsFormRow(title: L10n.string("field.ata_standby")) { + TextField(L10n.string("app_settings.blank_uses_device_default"), text: $editor.draft.ataStandby) + .frame(width: 180) + } + } + + SettingsFormSection(title: L10n.string("app_settings.section.diagnostics"), contentWidth: contentWidth) { + SettingsFormRow(title: L10n.string("app_settings.helper_path")) { + TextField(L10n.string("value.auto"), text: $editor.draft.helperPathOverride) + .frame(maxWidth: 420) + } + Toggle(L10n.string("app_settings.show_raw_events"), isOn: $editor.draft.showRawBackendEventsByDefault) + } + + SettingsFormSection(title: L10n.string("app_settings.section.updates"), contentWidth: contentWidth) { + Toggle(L10n.string("app_settings.check_updates_on_launch"), isOn: $editor.draft.checkForUpdatesOnLaunch) + SettingsFormRow(title: L10n.string("app_settings.version_url")) { + TextField(L10n.string("value.auto"), text: $editor.draft.versionCheckURL) + .frame(maxWidth: 420) + } + HStack(spacing: 10) { + Button { + appStore.appUpdateStore.checkNow(settings: appStore.appSettingsStore.settings) + } label: { + Label(L10n.string("app_settings.check_now"), systemImage: "arrow.clockwise") + } + .disabled(appStore.appUpdateStore.isChecking) + + if appStore.appUpdateStore.isChecking { + ProgressView() + .controlSize(.small) + } + Text(updateStatusText) + .font(.caption) + .foregroundStyle(updateStatusColor) + } + } + + SettingsFormSection(title: L10n.string("app_settings.section.privacy"), contentWidth: contentWidth) { + Toggle(L10n.string("app_settings.telemetry_enabled"), isOn: $editor.draft.telemetryEnabled) + } + + SettingsFormSection(title: L10n.string("app_settings.section.time_machine"), contentWidth: contentWidth) { + Toggle(L10n.string("app_settings.time_machine_warnings"), isOn: $editor.draft.timeMachineWarningsEnabled) + } + + if let message = editor.validationError ?? editor.errorMessage ?? appStore.appSettingsStore.error?.localizedDescription { + Text(message) + .font(.caption) + .foregroundStyle(.red) + .frame(maxWidth: contentWidth, alignment: .leading) + } + + actionBar + .frame(maxWidth: contentWidth, alignment: .leading) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.string("app_settings.title")) + .font(.title2.weight(.semibold)) + Text(L10n.string("app_settings.subtitle")) + .foregroundStyle(.secondary) + } + } + + private var actionBar: some View { + HStack(spacing: 10) { + Button { + Task { await editor.save(appStore: appStore) } + } label: { + Label(L10n.string("app_settings.save"), systemImage: "checkmark.circle") + } + .buttonStyle(.borderedProminent) + .disabled(!editor.canSave) + + Button(L10n.string("app_settings.reset_saved")) { + editor.resetDraft() + } + .disabled(editor.isSaving || !editor.hasChanges) + + Button(L10n.string("app_settings.restore_defaults")) { + editor.restoreDefaultsDraft() + } + .disabled(editor.isSaving) + + if editor.isSaving { + ProgressView() + .controlSize(.small) + } + } + } + + private var updateStatusText: String { + if let payload = appStore.appUpdateStore.payload { + return payload.summary + } + if let error = appStore.appUpdateStore.error { + return error.message + } + return appStore.appUpdateStore.state.title + } + + private var updateStatusColor: Color { + switch appStore.appUpdateStore.state { + case .updateAvailable, .failed: + return .yellow + case .current: + return .green + default: + return .secondary + } + } +} + +private struct SettingsFormSection: View { + let title: String + let contentWidth: CGFloat + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.headline) + VStack(alignment: .leading, spacing: 8) { + content() + } + } + .frame(maxWidth: contentWidth, alignment: .leading) + Divider() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct SettingsFormRow: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Text(title) + .frame(width: 220, alignment: .leading) + .foregroundStyle(.secondary) + content() + Spacer(minLength: 0) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift index 65fb36de..26cf6503 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift @@ -3,8 +3,10 @@ import SwiftUI public struct ContentView: View { @StateObject private var appStore: AppStore @StateObject private var addDeviceStore: AddDeviceFlowStore + @StateObject private var appSettingsEditorStore: AppSettingsEditorStore @StateObject private var dashboardStore: DashboardStore @State private var diagnosticsPresented = false + @State private var diagnosticsShowBackendEvents = true @State private var profilePendingDeletion: DeviceProfile? @State private var deleteErrorMessage: String? @@ -12,6 +14,7 @@ public struct ContentView: View { public init() { let appStore = AppStore() _appStore = StateObject(wrappedValue: appStore) + _appSettingsEditorStore = StateObject(wrappedValue: AppSettingsEditorStore(settings: appStore.appSettingsStore.settings)) _addDeviceStore = StateObject(wrappedValue: AddDeviceFlowStore( coordinator: appStore.operationCoordinator, registry: appStore.deviceRegistry, @@ -83,15 +86,26 @@ public struct ContentView: View { } .task { await appStore.start() + addDeviceStore.applyAppSettings(appStore.appSettingsStore.settings) + appSettingsEditorStore.sync(settings: appStore.appSettingsStore.settings) } .onChange(of: addDeviceStore.savedProfile) { _, profile in guard let profile else { return } appStore.select(profile) } + .onChange(of: appStore.appSettingsStore.settings) { _, settings in + addDeviceStore.applyAppSettings(settings) + appSettingsEditorStore.sync(settings: settings) + } + .onChange(of: diagnosticsPresented) { _, isPresented in + guard isPresented else { return } + diagnosticsShowBackendEvents = appStore.appSettingsStore.settings.showRawBackendEventsByDefault + } .sheet(isPresented: $diagnosticsPresented) { AppDiagnosticsView( store: appStore.appReadinessStore, events: appStore.backend.events, + showBackendEvents: $diagnosticsShowBackendEvents, helperPath: Binding( get: { appStore.backend.helperPath }, set: { appStore.backend.helperPath = $0 } @@ -194,6 +208,9 @@ public struct ContentView: View { if appStore.showingActivity { return "activity" } + if appStore.showingAppSettings { + return "settings" + } if appStore.showingAddDevice { return "add" } @@ -208,10 +225,13 @@ public struct ContentView: View { appStore.showAddDevice() } else if value == "activity" { appStore.showActivity() + } else if value == "settings" { + appStore.showAppSettings() } else if value == "all" { appStore.selectedDeviceID = nil appStore.showingAddDevice = false appStore.showingActivity = false + appStore.showingAppSettings = false } else if value.hasPrefix("device:") { let id = String(value.dropFirst("device:".count)) if let profile = appStore.deviceRegistry.profile(id: id) { @@ -228,6 +248,8 @@ public struct ContentView: View { .tag("all") Label(L10n.string("sidebar.activity"), systemImage: appStore.activityStore.hasActiveActivity ? "hourglass" : "clock") .tag("activity") + Label(L10n.string("sidebar.settings"), systemImage: "gearshape") + .tag("settings") Section(L10n.string("sidebar.devices")) { ForEach(appStore.deviceRegistry.profiles) { profile in @@ -264,6 +286,11 @@ public struct ContentView: View { activityStore: appStore.activityStore, registry: appStore.deviceRegistry ) + } else if appStore.showingAppSettings { + AppSettingsView( + appStore: appStore, + editor: appSettingsEditorStore + ) } else if appStore.showingAddDevice { AddDeviceView(store: addDeviceStore) } else if let profile = appStore.selectedProfile { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift index 177d37d7..c8d8b97b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift @@ -298,7 +298,15 @@ final class ActivityStore: ObservableObject { guard let operation else { return false } - return ["capabilities", "validate-install", "paths", "discover"].contains(operation) + return [ + "capabilities", + "discover", + "paths", + "set-telemetry", + "telemetry-identity", + "validate-install", + "version-check" + ].contains(operation) } private func updateSequence( diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift index d73c99ad..ee284fe5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift @@ -82,7 +82,11 @@ final class AddDeviceFlowStore: ObservableObject { private let appLane: OperationLane private var pendingProfileID: DeviceProfile.ID? + private var pendingExistingProfileID: DeviceProfile.ID? private var pendingDiscoveredDevice: DiscoveredDevice? + private var defaultDeviceSettings: DeviceProfileSettings = AppSettings.default.defaultDeviceSettings + private var appliedDefaultDeviceSettings: DeviceProfileSettings = AppSettings.default.defaultDeviceSettings + private var appliedDefaultBonjourTimeout = AppSettings.default.defaultBonjourTimeoutSeconds private var activeOperation: ActiveOperation? private var activeLaneKey: OperationLaneKey? private var lastProcessedEventCounts: [OperationLaneKey: Int] = [:] @@ -153,7 +157,7 @@ final class AddDeviceFlowStore: ObservableObject { } var bonjourTimeoutValue: Double? { - nonNegativeDouble(bonjourTimeout) + ValueParsers.nonNegativeDouble(bonjourTimeout) } var canConfigure: Bool { @@ -251,8 +255,7 @@ final class AddDeviceFlowStore: ObservableObject { 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 configureSettings = existing?.settings ?? defaultDeviceSettings let context = DeviceRuntimeContext( profileID: profileID, @@ -265,11 +268,15 @@ final class AddDeviceFlowStore: ObservableObject { guard !lane.isBusy else { pendingProfileID = nil + pendingExistingProfileID = nil pendingDiscoveredDevice = nil rejectRun(L10n.string("operation.error.already_running")) return } resetRunState(clearDevices: false) + pendingProfileID = profileID + pendingExistingProfileID = existing?.id + pendingDiscoveredDevice = selectedDevice lastProcessedEventCounts[laneKey] = 0 switch coordinator.run( operation: "configure", @@ -277,7 +284,12 @@ final class AddDeviceFlowStore: ObservableObject { host: targetHost, selectedRecord: selectedDevice?.rawRecord, password: password, - debugLogging: debugLogging + debugLogging: configureSettings.debugLogging, + internalShareUseDiskRoot: configureSettings.internalShareUseDiskRoot, + anyProtocol: configureSettings.anyProtocol, + ataIdleSeconds: configureSettings.ataIdleSeconds, + ataStandby: configureSettings.ataStandby, + includeAtaStandby: true ), context: context, activeDeviceID: profileID, @@ -289,6 +301,7 @@ final class AddDeviceFlowStore: ObservableObject { state = .configuring case .rejected(let message): pendingProfileID = nil + pendingExistingProfileID = nil pendingDiscoveredDevice = nil rejectRun(message) } @@ -326,6 +339,7 @@ final class AddDeviceFlowStore: ObservableObject { error = nil currentStage = nil pendingProfileID = nil + pendingExistingProfileID = nil pendingDiscoveredDevice = nil activeOperation = nil activeLaneKey = nil @@ -352,6 +366,7 @@ final class AddDeviceFlowStore: ObservableObject { error = nil currentStage = nil pendingProfileID = nil + pendingExistingProfileID = nil pendingDiscoveredDevice = nil activeOperation = nil activeLaneKey = nil @@ -366,6 +381,19 @@ final class AddDeviceFlowStore: ObservableObject { coordinator.cancel(laneKey: activeLaneKey) } + func applyAppSettings(_ settings: AppSettings) { + let previousDefaultTimeout = Self.timeoutText(appliedDefaultBonjourTimeout) + if bonjourTimeout == previousDefaultTimeout { + bonjourTimeout = Self.timeoutText(settings.defaultBonjourTimeoutSeconds) + } + appliedDefaultBonjourTimeout = settings.defaultBonjourTimeoutSeconds + defaultDeviceSettings = settings.defaultDeviceSettings + if debugLogging == appliedDefaultDeviceSettings.debugLogging { + debugLogging = settings.defaultDeviceSettings.debugLogging + } + appliedDefaultDeviceSettings = settings.defaultDeviceSettings + } + private func resetRunState(clearDevices: Bool) { let laneKey = activeLaneKey ?? (state == .discovering ? .app : nil) if let laneKey { @@ -383,6 +411,7 @@ final class AddDeviceFlowStore: ObservableObject { savedProfile = nil activeOperation = nil activeLaneKey = nil + pendingExistingProfileID = nil if clearDevices { devices = [] selectedDeviceID = nil @@ -466,20 +495,29 @@ final class AddDeviceFlowStore: ObservableObject { state = .savingProfile let profileID = pendingProfileID ?? UUID().uuidString.lowercased() + let existingProfileID = pendingExistingProfileID let pendingDiscoveredDevice = pendingDiscoveredDevice let password = password + let overrides = ConfiguredDeviceProfileOverrides( + displayName: nil, + settings: existingProfileID == nil ? defaultDeviceSettings : nil + ) Task { @MainActor in do { savedProfile = try await profileSaver.saveConfiguredDevice( configuredDevice: configured, discoveredDevice: pendingDiscoveredDevice, password: password, - preferredID: profileID + preferredID: profileID, + existingProfileID: existingProfileID, + overrides: overrides ) error = nil state = .saved activeOperation = nil activeLaneKey = nil + pendingProfileID = nil + pendingExistingProfileID = nil } catch { failProfileSave(error) } @@ -498,6 +536,7 @@ final class AddDeviceFlowStore: ObservableObject { } activeOperation = nil activeLaneKey = nil + pendingExistingProfileID = nil } private func failFromResult(_ event: BackendEvent) { @@ -509,6 +548,7 @@ final class AddDeviceFlowStore: ObservableObject { state = .failed activeOperation = nil activeLaneKey = nil + pendingExistingProfileID = nil } private func failContract(_ error: Error) { @@ -520,6 +560,7 @@ final class AddDeviceFlowStore: ObservableObject { state = .failed activeOperation = nil activeLaneKey = nil + pendingExistingProfileID = nil } private func failProfileSave(_ error: Error) { @@ -531,6 +572,7 @@ final class AddDeviceFlowStore: ObservableObject { state = .failed activeOperation = nil activeLaneKey = nil + pendingExistingProfileID = nil } private func failLocally(_ message: String) { @@ -543,6 +585,7 @@ final class AddDeviceFlowStore: ObservableObject { state = .failed activeOperation = nil activeLaneKey = nil + pendingExistingProfileID = nil } private func rejectRun(_ message: String) { @@ -555,14 +598,14 @@ final class AddDeviceFlowStore: ObservableObject { state = .failed activeOperation = nil activeLaneKey = nil + pendingExistingProfileID = 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 + private static func timeoutText(_ value: Double) -> String { + guard value.rounded() == value else { + return String(value) } - return value + return String(Int(value)) } private var hasSelectedTarget: Bool { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift new file mode 100644 index 00000000..f88e4e0a --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift @@ -0,0 +1,141 @@ +import Combine +import Foundation + +enum AppUpdateState: String, Equatable { + case idle + case checking + case current + case updateAvailable + case failed + + var title: String { + switch self { + case .idle: + return L10n.string("app_update.state.idle") + case .checking: + return L10n.string("app_update.state.checking") + case .current: + return L10n.string("app_update.state.current") + case .updateAvailable: + return L10n.string("app_update.state.update_available") + case .failed: + return L10n.string("app_update.state.failed") + } + } +} + +@MainActor +final class AppUpdateStore: ObservableObject { + @Published private(set) var state: AppUpdateState = .idle + @Published private(set) var payload: VersionCheckPayload? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let lane: OperationLane + + private var activeOperation: ActiveOperation? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + init(coordinator: OperationCoordinator) { + self.lane = coordinator.lane(for: .localPath("app-update")) + lane.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var isChecking: Bool { + lane.backend.isRunning + } + + func checkNow(settings: AppSettings) { + guard !lane.isBusy else { + state = .failed + error = BackendErrorViewModel( + operation: "version-check", + code: "operation_rejected", + message: L10n.string("operation.error.already_running") + ) + return + } + lane.clear() + lastProcessedEventCount = 0 + state = .checking + payload = nil + error = nil + currentStage = nil + + var params: [String: JSONValue] = [:] + let url = settings.versionCheckURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !url.isEmpty { + params["url"] = .string(url) + } + + switch lane.run(operation: "version-check", params: params, context: nil, activeDeviceID: nil) { + case .started(let operation): + activeOperation = operation + case .rejected(let message): + state = .failed + activeOperation = nil + error = BackendErrorViewModel( + operation: "version-check", + code: "operation_rejected", + message: message + ) + } + } + + 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 == "version-check" else { + return + } + guard activeOperation?.operation == event.operation else { + return + } + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + if event.type == "error" { + error = BackendErrorViewModel(event: event) + state = .failed + activeOperation = nil + return + } + guard event.type == "result" else { + return + } + do { + let result = try event.decodePayload(VersionCheckPayload.self) + payload = result + state = result.shouldBlock ? .updateAvailable : .current + error = nil + activeOperation = nil + } catch { + self.error = BackendErrorViewModel( + operation: "version-check", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .failed + activeOperation = nil + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index c2afe594..9dc714eb 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -45,10 +45,12 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { self.maintenanceStore = MaintenanceStore(coordinator: appStore.operationCoordinator, laneKey: laneKey) self.profileEditorStore = DeviceProfileEditorStore(profile: profile, appStore: appStore) applyProfileSettings(profile.settings) + doctorStore.applyAppSettings(appStore.appSettingsStore.settings) forwardChildChanges() forwardLaneEvents() observeSnapshots() observeProfileEditor() + observeAppSettings() } func summary(for profile: DeviceProfile) -> DeviceDashboardSummary { @@ -350,6 +352,14 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { .store(in: &cancellables) } + private func observeAppSettings() { + appStore.appSettingsStore.$settings + .sink { [weak self] settings in + self?.doctorStore.applyAppSettings(settings) + } + .store(in: &cancellables) + } + private func retry(error: BackendErrorViewModel, profile: DeviceProfile) -> Bool { switch error.operation { case "doctor": diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift index 72b0ae03..07ff7039 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift @@ -45,7 +45,7 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { let registry: DeviceRegistryStore private let lane: OperationLane - private let timeout: Double + private var timeout: Double private var isMonitoring = false private var pendingRefresh = false private var activeOperation: ActiveOperation? @@ -56,7 +56,7 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { coordinator: OperationCoordinator, readinessStore: AppReadinessStore, registry: DeviceRegistryStore, - timeout: Double = 4 + timeout: Double = AppSettings.default.defaultBonjourTimeoutSeconds ) { self.coordinator = coordinator self.readinessStore = readinessStore @@ -117,6 +117,10 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { runDiscoverWhenPossible() } + func applyAppSettings(_ settings: AppSettings) { + timeout = settings.defaultBonjourTimeoutSeconds + } + func matchingProfile(for device: DiscoveredDevice) -> DeviceProfile? { registry.matchingProfile(host: device.host, bonjourFullname: device.fullname) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift index f54dcd22..84512197 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift @@ -104,6 +104,7 @@ final class DoctorStore: ObservableObject { private let laneKey: OperationLaneKey? private var activeOperation: ActiveOperation? + private var appliedDefaultBonjourTimeout = AppSettings.default.defaultBonjourTimeoutSeconds private var lastProcessedEventCount = 0 private var cancellables: Set = [] @@ -157,7 +158,7 @@ final class DoctorStore: ObservableObject { } var bonjourTimeoutValue: Double? { - nonNegativeDouble(bonjourTimeout) + ValueParsers.nonNegativeDouble(bonjourTimeout) } @discardableResult @@ -213,6 +214,14 @@ final class DoctorStore: ObservableObject { backend.cancel() } + func applyAppSettings(_ settings: AppSettings) { + let previousDefaultTimeout = Self.timeoutText(appliedDefaultBonjourTimeout) + if bonjourTimeout == previousDefaultTimeout { + bonjourTimeout = Self.timeoutText(settings.defaultBonjourTimeoutSeconds) + } + appliedDefaultBonjourTimeout = settings.defaultBonjourTimeoutSeconds + } + private func process(_ events: [BackendEvent]) { if events.count < lastProcessedEventCount { lastProcessedEventCount = 0 @@ -302,12 +311,11 @@ final class DoctorStore: ObservableObject { 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 + private static func timeoutText(_ value: Double) -> String { + guard value.rounded() == value else { + return String(value) } - return value + return String(Int(value)) } private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift index b34bf430..23aa594e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift @@ -89,6 +89,10 @@ enum OperationTimelineBuilder { return L10n.string("timeline.operation.uninstall") case "capabilities", "validate-install", "paths": return L10n.string("timeline.operation.readiness") + case "set-telemetry", "telemetry-identity": + return L10n.string("timeline.operation.telemetry") + case "version-check": + return L10n.string("timeline.operation.version_check") case "flash": return L10n.string("timeline.operation.flash") default: diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift index 55a054e9..22221b5d 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -293,6 +293,88 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls[0].params["debug_logging"], .bool(false)) } + func testNewManualProfileUsesAppDefaultDeviceSettings() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "root@10.0.0.2")) + ]) + ]) + let defaultSettings = DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 45, + ataIdleSeconds: 600, + ataStandby: 900 + ) + var appSettings = AppSettings.default + appSettings.defaultDeviceSettings = defaultSettings + fixture.store.applyAppSettings(appSettings) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.store.savedProfile?.settings, defaultSettings) + XCTAssertEqual(fixture.runner.calls[0].params["debug_logging"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[0].params["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[0].params["any_protocol"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[0].params["ata_idle_seconds"], .number(600)) + XCTAssertEqual(fixture.runner.calls[0].params["ata_standby"], .number(900)) + } + + func testExistingProfileSettingsAreNotClobberedByAppDefaults() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "10.0.0.2")) + ]) + ]) + let existing = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + var editedExisting = existing + editedExisting.settings = DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: false, + anyProtocol: false, + debugLogging: false, + mountWaitSeconds: 99, + ataIdleSeconds: 111, + ataStandby: nil + ) + _ = try await fixture.registry.updateProfile(editedExisting) + var appSettings = AppSettings.default + appSettings.defaultDeviceSettings = DeviceProfileSettings( + nbnsEnabled: true, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 1, + ataIdleSeconds: 2, + ataStandby: 3 + ) + fixture.store.applyAppSettings(appSettings) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.store.savedProfile?.settings, editedExisting.settings) + XCTAssertEqual(fixture.runner.calls[0].params["debug_logging"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[0].params["internal_share_use_disk_root"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[0].params["any_protocol"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[0].params["ata_idle_seconds"], .number(111)) + XCTAssertEqual(fixture.runner.calls[0].params["ata_standby"], .string("")) + } + func testConfigureRejectedWhileAnotherOperationRunsSavesNothing() async throws { let fixture = try await makeStore(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift new file mode 100644 index 00000000..d81eaff4 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AppSettingsStoreTests: XCTestCase { + func testLoadMissingSettingsUsesDefaults() async throws { + let temp = try TemporaryDirectory() + let store = AppSettingsStore(settingsURL: temp.url.appendingPathComponent("settings.json")) + + await store.load() + + XCTAssertEqual(store.state, .loaded) + XCTAssertEqual(store.settings, .default) + XCTAssertNil(store.error) + } + + func testSaveAndLoadRoundTripsAllSettings() async throws { + let temp = try TemporaryDirectory() + let settingsURL = temp.url.appendingPathComponent("settings.json") + let saved = AppSettings( + defaultBonjourTimeoutSeconds: 12.5, + defaultDeviceSettings: DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 45, + ataIdleSeconds: 600, + ataStandby: 900 + ), + telemetryEnabled: false, + helperPathOverride: "/tmp/tcapsule", + showRawBackendEventsByDefault: false, + checkForUpdatesOnLaunch: false, + versionCheckURL: "https://example.invalid/version.json", + timeMachineWarningsEnabled: false + ) + + let writer = AppSettingsStore(settingsURL: settingsURL) + try await writer.save(saved) + let reader = AppSettingsStore(settingsURL: settingsURL) + await reader.load() + + XCTAssertEqual(reader.state, .loaded) + XCTAssertEqual(reader.settings, saved) + } + + func testCorruptSettingsFailsWithoutReplacingDefaults() async throws { + let temp = try TemporaryDirectory() + let settingsURL = temp.url.appendingPathComponent("settings.json") + try "{".write(to: settingsURL, atomically: true, encoding: .utf8) + let store = AppSettingsStore(settingsURL: settingsURL) + + await store.load() + + XCTAssertEqual(store.state, .failed) + XCTAssertEqual(store.settings, .default) + XCTAssertNotNil(store.error) + } + + func testDraftValidationRejectsBadNumbersAndURLs() throws { + var draft = AppSettingsDraft(settings: .default) + draft.defaultBonjourTimeoutSeconds = "-1" + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? AppSettingsValidationError, .invalidBonjourTimeout) + } + + draft = AppSettingsDraft(settings: .default) + draft.ataStandby = "abc" + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? AppSettingsValidationError, .invalidAtaStandby) + } + + draft = AppSettingsDraft(settings: .default) + draft.versionCheckURL = "file:///tmp/version.json" + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? AppSettingsValidationError, .invalidVersionCheckURL) + } + } + + func testSavingSettingsAppliesHelperPathAndRunsTelemetrySyncOnlyWhenNeeded() async throws { + let temp = try TemporaryDirectory() + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "set-telemetry", ok: true, payload: telemetryPayload(enabled: false)) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let settingsStore = AppSettingsStore(settingsURL: temp.url.appendingPathComponent("settings.json")) + await settingsStore.load() + let appStore = AppStore( + appReadinessStore: AppReadinessStore(backend: coordinator.appLane.backend), + appSettingsStore: settingsStore, + deviceRegistry: DeviceRegistryStore(applicationSupportURL: temp.url), + operationCoordinator: coordinator, + passwordStore: InMemoryPasswordStore() + ) + + var settings = AppSettings.default + settings.telemetryEnabled = false + try await appStore.saveAppSettings(settings) + + try await waitUntilStoreState { runner.calls.map(\.operation).contains("set-telemetry") } + XCTAssertEqual(runner.calls.first?.params["enabled"], .bool(false)) + + var helperSettings = settings + helperSettings.helperPathOverride = "/tmp/tcapsule-helper" + try await appStore.saveAppSettings(helperSettings) + + XCTAssertEqual(appStore.backend.helperPath, "/tmp/tcapsule-helper") + } + + private func telemetryPayload(enabled: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "install_id": .string("install-one"), + "telemetry_enabled": .bool(enabled), + "bootstrap_path": .string("/tmp/.bootstrap"), + "summary": .string(enabled ? "telemetry is enabled." : "telemetry is disabled.") + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift index 5ff38d8a..3b14c25c 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift @@ -17,4 +17,11 @@ final class HostCompatibilityPolicyTests: XCTestCase { XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 3, patchVersion: 9))) XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 5, patchVersion: 0))) } + + func testDisabledPolicyStillSuppressesWarnings() { + XCTAssertNil(HostCompatibilityPolicy.warning( + enabled: false, + for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 5) + )) + } } diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index 298ba2fb..7f9ed624 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -4,6 +4,8 @@ from typing import Mapping from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.cli.version_check import VersionCheckResult +from timecapsulesmb.identity import InstallIdentity from timecapsulesmb.services.app import jsonable from timecapsulesmb.services.doctor import doctor_status_counts @@ -87,6 +89,33 @@ def install_validation_payload(*, ok: bool, checks: list[object]) -> dict[str, o }) +def telemetry_identity_payload(*, identity: InstallIdentity, bootstrap_path: str) -> dict[str, object]: + return _with_schema({ + "install_id": identity.install_id, + "telemetry_enabled": identity.telemetry_enabled, + "bootstrap_path": bootstrap_path, + "summary": "telemetry is enabled." if identity.telemetry_enabled else "telemetry is disabled.", + }) + + +def version_check_payload(result: VersionCheckResult) -> dict[str, object]: + summary = "update required." if result.should_block else "TimeCapsuleSMB is up to date." + if result.source == "unavailable": + summary = "version metadata is unavailable." + return _with_schema({ + "should_block": result.should_block, + "checked_url": result.checked_url, + "message": result.message, + "download_url": result.download_url, + "local_version_code": result.local_version_code, + "current_version": result.current_version, + "min_supported_version": result.min_supported_version, + "latest_tag": result.latest_tag, + "source": result.source, + "summary": summary, + }) + + def configure_payload( *, config_path: str, diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py index 8a961c45..e4af4d74 100644 --- a/src/timecapsulesmb/app/ops/__init__.py +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -16,7 +16,10 @@ capabilities_operation, discover_operation, paths_operation, + set_telemetry_operation, + telemetry_identity_operation, validate_install_operation, + version_check_operation, ) from timecapsulesmb.services.app import OperationResult @@ -31,6 +34,9 @@ "fsck": fsck_operation, "paths": paths_operation, "repair-xattrs": repair_xattrs_operation, + "set-telemetry": set_telemetry_operation, + "telemetry-identity": telemetry_identity_operation, "uninstall": uninstall_operation, "validate-install": validate_install_operation, + "version-check": version_check_operation, } diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py index caa7a728..060d3a19 100644 --- a/src/timecapsulesmb/app/ops/readiness.py +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -1,9 +1,18 @@ from __future__ import annotations import hashlib - -from timecapsulesmb.app.contracts import capabilities_payload, discover_payload, install_validation_payload, paths_payload +from urllib.parse import urlparse + +from timecapsulesmb.app.contracts import ( + capabilities_payload, + discover_payload, + install_validation_payload, + paths_payload, + telemetry_identity_payload, + version_check_payload, +) from timecapsulesmb.app.events import EventSink +from timecapsulesmb.cli.version_check import VERSION_CHECK_URL, check_client_version 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 ( @@ -22,10 +31,14 @@ paths_to_jsonable, validate_install, ) +from timecapsulesmb.identity import load_install_identity, set_telemetry_enabled from timecapsulesmb.services.app import ( + AppOperationError, OperationResult, + bool_param, config_path, float_param, + string_param, ) @@ -95,8 +108,11 @@ def capabilities_operation(params: dict[str, object], sink: EventSink) -> Operat "fsck", "paths", "repair-xattrs", + "set-telemetry", + "telemetry-identity", "uninstall", "validate-install", + "version-check", ], distribution_root=str(app_paths.distribution_root), artifact_manifest_sha256=manifest_hash, @@ -126,3 +142,43 @@ def validate_install_operation(params: dict[str, object], sink: EventSink) -> Op details=check.details, ) return OperationResult(ok, install_validation_payload(ok=ok, checks=install_checks_to_jsonable(checks))) + + +def telemetry_identity_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "telemetry-identity" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "read_bootstrap") + identity = load_install_identity(app_paths.bootstrap_path) + return OperationResult( + True, + telemetry_identity_payload(identity=identity, bootstrap_path=str(app_paths.bootstrap_path)), + ) + + +def set_telemetry_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "set-telemetry" + if "enabled" not in params: + raise AppOperationError("missing required parameter: enabled", code="validation_failed") + enabled = bool_param(params, "enabled") + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "write_bootstrap") + identity = set_telemetry_enabled(enabled, app_paths.bootstrap_path) + return OperationResult( + True, + telemetry_identity_payload(identity=identity, bootstrap_path=str(app_paths.bootstrap_path)), + ) + + +def version_check_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "version-check" + url = string_param(params, "url", VERSION_CHECK_URL).strip() or VERSION_CHECK_URL + parsed_url = urlparse(url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise AppOperationError("url must be an HTTP/HTTPS URL", code="validation_failed") + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "check_version") + result = check_client_version(url=url, cache_path=app_paths.version_check_cache_path) + return OperationResult(True, version_check_payload(result)) diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py index 88354142..3bc47caf 100644 --- a/src/timecapsulesmb/app/stage_policy.py +++ b/src/timecapsulesmb/app/stage_policy.py @@ -40,8 +40,14 @@ def to_jsonable(self) -> dict[str, object]: ("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."), + ("set-telemetry", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve local app state paths."), + ("set-telemetry", "write_bootstrap"): StagePolicy(LOCAL_WRITE, False, "Update local telemetry preference."), + ("telemetry-identity", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve local app state paths."), + ("telemetry-identity", "read_bootstrap"): StagePolicy(LOCAL_READ, True, "Read local telemetry preference."), ("validate-install", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve app installation paths."), ("validate-install", "validate_install"): StagePolicy(LOCAL_READ, True, "Validate local helper and artifact prerequisites."), + ("version-check", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve version check cache path."), + ("version-check", "check_version"): StagePolicy(LOCAL_READ, True, "Fetch or read version metadata."), ("configure", "load_existing_config"): StagePolicy(LOCAL_READ, True, "Read the existing .env configuration."), ("configure", "ssh_probe"): StagePolicy(REMOTE_READ, True, "Probe SSH reachability and device compatibility."), ("configure", "acp_enable_ssh"): StagePolicy(REMOTE_WRITE, False, "Request SSH enablement through AirPort ACP."), diff --git a/src/timecapsulesmb/cli/version_check.py b/src/timecapsulesmb/cli/version_check.py index 6557538e..f4b24ad4 100644 --- a/src/timecapsulesmb/cli/version_check.py +++ b/src/timecapsulesmb/cli/version_check.py @@ -23,7 +23,9 @@ @dataclass(frozen=True) class VersionMetadata: + current_version: int min_supported_version: int + latest_tag: str | None download_url: str message: str @@ -34,6 +36,11 @@ class VersionCheckResult: checked_url: str = VERSION_CHECK_URL message: str = DEFAULT_UNSUPPORTED_MESSAGE download_url: str = DEFAULT_DOWNLOAD_URL + local_version_code: int = CLI_VERSION_CODE + current_version: int | None = None + min_supported_version: int | None = None + latest_tag: str | None = None + source: str = "unavailable" UrlOpen = Callable[..., Any] @@ -62,8 +69,13 @@ def parse_version_metadata(payload: object) -> VersionMetadata | None: message = payload.get("message") if not isinstance(message, str) or not message.strip(): message = DEFAULT_UNSUPPORTED_MESSAGE + latest_tag = payload.get("latest_tag") + if not isinstance(latest_tag, str) or not latest_tag.strip(): + latest_tag = None return VersionMetadata( + current_version=current_version, min_supported_version=min_supported_version, + latest_tag=latest_tag.strip() if latest_tag else None, download_url=download_url.strip(), message=message.strip(), ) @@ -157,7 +169,7 @@ def check_client_version( opener=opener, ) except Exception: - return VersionCheckResult(should_block=False, checked_url=url) + return VersionCheckResult(should_block=False, checked_url=url, local_version_code=local_version_code) def _check_client_version( @@ -173,12 +185,20 @@ def _check_client_version( cached_payload = load_fresh_cached_payload(cache_path=cache_path, now=timestamp) cached_metadata = parse_version_metadata(cached_payload) if cached_metadata is not None and local_version_code >= cached_metadata.min_supported_version: - return VersionCheckResult(should_block=False, checked_url=url) + return VersionCheckResult( + should_block=False, + checked_url=url, + local_version_code=local_version_code, + current_version=cached_metadata.current_version, + min_supported_version=cached_metadata.min_supported_version, + latest_tag=cached_metadata.latest_tag, + source="cache", + ) fetched_payload = fetch_version_payload(url=url, timeout=timeout, opener=opener) fetched_metadata = parse_version_metadata(fetched_payload) if fetched_metadata is None: - return VersionCheckResult(should_block=False, checked_url=url) + return VersionCheckResult(should_block=False, checked_url=url, local_version_code=local_version_code) save_cached_payload(fetched_payload, cache_path=cache_path, now=timestamp) if local_version_code < fetched_metadata.min_supported_version: @@ -187,8 +207,21 @@ def _check_client_version( checked_url=url, message=fetched_metadata.message, download_url=fetched_metadata.download_url, + local_version_code=local_version_code, + current_version=fetched_metadata.current_version, + min_supported_version=fetched_metadata.min_supported_version, + latest_tag=fetched_metadata.latest_tag, + source="network", ) - return VersionCheckResult(should_block=False, checked_url=url) + return VersionCheckResult( + should_block=False, + checked_url=url, + local_version_code=local_version_code, + current_version=fetched_metadata.current_version, + min_supported_version=fetched_metadata.min_supported_version, + latest_tag=fetched_metadata.latest_tag, + source="network", + ) def render_version_block_message(result: VersionCheckResult) -> str: diff --git a/src/timecapsulesmb/identity.py b/src/timecapsulesmb/identity.py index 50199a98..17966629 100644 --- a/src/timecapsulesmb/identity.py +++ b/src/timecapsulesmb/identity.py @@ -64,3 +64,11 @@ def ensure_install_id(path: Path | None = None) -> str: resolved_path.parent.mkdir(parents=True, exist_ok=True) resolved_path.write_text(render_bootstrap_text(install_id, telemetry_enabled=identity.telemetry_enabled)) return install_id + + +def set_telemetry_enabled(enabled: bool, path: Path | None = None) -> InstallIdentity: + resolved_path = path or default_bootstrap_path() + install_id = ensure_install_id(resolved_path) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + resolved_path.write_text(render_bootstrap_text(install_id, telemetry_enabled=enabled)) + return load_install_identity(resolved_path) diff --git a/tests/test_app_api.py b/tests/test_app_api.py index b33563cd..d9d7ed64 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -21,6 +21,7 @@ from timecapsulesmb.app.events import AppEvent, EventSink from timecapsulesmb import repair_xattrs as repair_xattrs_domain from timecapsulesmb.app import contracts, helper, service +from timecapsulesmb.cli.version_check import VersionCheckResult from timecapsulesmb.cli import main as cli_main from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, ConfigError, parse_env_file @@ -293,9 +294,95 @@ def test_capabilities_returns_helper_contract_details(self) -> None: self.assertEqual(payload["api_schema_version"], 1) self.assertIn("deploy", payload["operations"]) self.assertIn("capabilities", payload["operations"]) + self.assertIn("set-telemetry", payload["operations"]) + self.assertIn("version-check", payload["operations"]) self.assertIn("helper_version", payload) self.assertIn("artifact_manifest_sha256", payload) + def test_set_telemetry_operation_updates_bootstrap_preference(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + bootstrap_path = Path(tmp) / ".bootstrap" + app_paths = SimpleNamespace(bootstrap_path=bootstrap_path) + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.readiness.resolve_app_paths", return_value=app_paths): + rc = service.run_api_request( + {"operation": "set-telemetry", "params": {"enabled": False}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + stages = collector.events_of_type("stage") + self.assertEqual([stage["stage"] for stage in stages], ["resolve_paths", "write_bootstrap"]) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertFalse(payload["telemetry_enabled"]) + self.assertEqual(payload["bootstrap_path"], str(bootstrap_path)) + self.assertIn("TELEMETRY=false", bootstrap_path.read_text()) + + def test_telemetry_identity_operation_reads_current_bootstrap_preference(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + bootstrap_path = Path(tmp) / ".bootstrap" + bootstrap_path.write_text("INSTALL_ID=install-one\nTELEMETRY=false\n") + app_paths = SimpleNamespace(bootstrap_path=bootstrap_path) + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.readiness.resolve_app_paths", return_value=app_paths): + rc = service.run_api_request({"operation": "telemetry-identity", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertEqual(payload["install_id"], "install-one") + self.assertFalse(payload["telemetry_enabled"]) + + def test_version_check_operation_returns_structured_update_status(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app_paths = SimpleNamespace(version_check_cache_path=Path(tmp) / "version-cache.json") + collector = CollectingSink() + result = VersionCheckResult( + should_block=True, + checked_url="https://example.invalid/version.json", + message="Please update.", + download_url="https://example.invalid/download", + local_version_code=20004, + current_version=20005, + min_supported_version=20005, + latest_tag="v2.0.5", + source="network", + ) + + with mock.patch("timecapsulesmb.app.ops.readiness.resolve_app_paths", return_value=app_paths): + with mock.patch("timecapsulesmb.app.ops.readiness.check_client_version", return_value=result) as check: + rc = service.run_api_request( + { + "operation": "version-check", + "params": {"url": "https://example.invalid/version.json"}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + check.assert_called_once_with( + url="https://example.invalid/version.json", + cache_path=app_paths.version_check_cache_path, + ) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertTrue(payload["should_block"]) + self.assertEqual(payload["current_version"], 20005) + self.assertEqual(payload["latest_tag"], "v2.0.5") + self.assertEqual(payload["source"], "network") + + def test_version_check_operation_rejects_non_http_url(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request( + {"operation": "version-check", "params": {"url": "file:///tmp/version.json"}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + def test_missing_params_defaults_to_empty_object(self) -> None: collector = CollectingSink() diff --git a/tests/test_identity.py b/tests/test_identity.py index d29c0167..97581917 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -12,7 +12,7 @@ if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) -from timecapsulesmb.identity import ensure_install_id, load_install_identity, parse_bootstrap_values +from timecapsulesmb.identity import ensure_install_id, load_install_identity, parse_bootstrap_values, set_telemetry_enabled class IdentityTests(unittest.TestCase): @@ -35,6 +35,21 @@ def test_ensure_install_id_preserves_telemetry_false(self) -> None: self.assertEqual(identity.install_id, install_id) self.assertFalse(identity.telemetry_enabled) + def test_set_telemetry_enabled_preserves_install_id_and_updates_preference(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".bootstrap" + path.write_text("INSTALL_ID=install-one\n") + + disabled = set_telemetry_enabled(False, path) + self.assertEqual(disabled.install_id, "install-one") + self.assertFalse(disabled.telemetry_enabled) + self.assertEqual(parse_bootstrap_values(path), {"INSTALL_ID": "install-one", "TELEMETRY": "false"}) + + enabled = set_telemetry_enabled(True, path) + self.assertEqual(enabled.install_id, "install-one") + self.assertTrue(enabled.telemetry_enabled) + self.assertEqual(parse_bootstrap_values(path), {"INSTALL_ID": "install-one"}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 0a5fcb7b..9257fb8a 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -81,6 +81,10 @@ def test_supported_client_fetches_and_caches_successful_response(self) -> None: ) self.assertFalse(result.should_block) + self.assertEqual(result.source, "network") + self.assertEqual(result.current_version, 20004) + self.assertEqual(result.min_supported_version, 20004) + self.assertEqual(result.latest_tag, "v2.0.4") self.assertEqual(len(calls), 1) request, timeout = calls[0] self.assertEqual(request.full_url, VERSION_CHECK_URL) @@ -114,6 +118,9 @@ def test_outdated_client_blocks_with_remote_message_and_download_url(self) -> No self.assertTrue(result.should_block) self.assertEqual(result.message, message) self.assertEqual(result.download_url, download_url) + self.assertEqual(result.source, "network") + self.assertEqual(result.current_version, 20005) + self.assertEqual(result.min_supported_version, 20005) self.assertEqual(len(calls), 1) def test_invalid_or_unreachable_version_metadata_fails_open(self) -> None: @@ -198,6 +205,8 @@ def opener(_request, timeout): ) self.assertFalse(result.should_block) + self.assertEqual(result.source, "cache") + self.assertEqual(result.current_version, 20004) self.assertEqual(calls, []) def test_stale_cache_fetches_remote_metadata(self) -> None: From 3f77b75e8c6f61632c11440b6f21d6da6440fd65 Mon Sep 17 00:00:00 2001 From: James Chang Date: Fri, 22 May 2026 05:59:28 -0700 Subject: [PATCH 034/129] Implement schema v4 telemetry across CLI and GUI API operations --- .../Backend/HelperLocator.swift | 3 + .../HelperLocatorTests.swift | 19 ++ src/timecapsulesmb/app/events.py | 7 + src/timecapsulesmb/app/ops/__init__.py | 11 + src/timecapsulesmb/app/service.py | 88 +++++- src/timecapsulesmb/cli/context.py | 45 ++- src/timecapsulesmb/telemetry/__init__.py | 42 ++- src/timecapsulesmb/telemetry/operation.py | 287 ++++++++++++++++++ tests/test_app_api.py | 113 +++++++ tests/test_telemetry.py | 50 ++- 10 files changed, 643 insertions(+), 22 deletions(-) create mode 100644 src/timecapsulesmb/telemetry/operation.py diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperLocator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperLocator.swift index d5472328..ccef0279 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperLocator.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperLocator.swift @@ -82,6 +82,9 @@ public struct HelperLocator { if let toolsBin = resolution.toolsBinURL, isDirectory(toolsBin) { output["PATH"] = pathByPrepending(toolsBin.path, to: output["PATH"]) } + if output["TCAPSULE_CLIENT"] == nil { + output["TCAPSULE_CLIENT"] = "macos_gui" + } output["PYTHONNOUSERSITE"] = "1" return output } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift index b7992e71..bc76b639 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift @@ -24,9 +24,28 @@ final class HelperLocatorTests: XCTestCase { XCTAssertNil(resolution.toolsBinURL) XCTAssertNotNil(environment["TCAPSULE_CONFIG"]) XCTAssertNotNil(environment["TCAPSULE_STATE_DIR"]) + XCTAssertEqual(environment["TCAPSULE_CLIENT"], "macos_gui") XCTAssertEqual(environment["PYTHONNOUSERSITE"], "1") } + func testLocatorPreservesExplicitTelemetryClientEnvironment() throws { + let temp = try TemporaryDirectory() + let helper = temp.url.appendingPathComponent("tcapsule") + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + let locator = HelperLocator( + environment: ["TCAPSULE_CLIENT": "integration_test"], + currentDirectory: temp.url, + bundle: .main, + fileManager: .default + ) + + let resolution = try locator.resolve(helperPath: helper.path) + let environment = locator.helperEnvironment(for: resolution) + + XCTAssertEqual(environment["TCAPSULE_CLIENT"], "integration_test") + } + func testLocatorUsesDeviceContextConfigWithoutChangingAppStateDirectory() throws { let temp = try TemporaryDirectory() let helper = temp.url.appendingPathComponent("tcapsule") diff --git a/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py index 9abdb1e4..c68cfe0b 100644 --- a/src/timecapsulesmb/app/events.py +++ b/src/timecapsulesmb/app/events.py @@ -60,6 +60,7 @@ def __init__( self.request_id = request_id or str(uuid.uuid4()) self.schema_version = schema_version self._current_stage_by_operation: dict[str, str] = {} + self._current_risk_by_operation: dict[str, str] = {} def with_request_id(self, request_id: str) -> "EventSink": return EventSink(self._emit, request_id=request_id, schema_version=self.schema_version) @@ -78,12 +79,18 @@ def emit(self, event: AppEvent) -> None: def current_stage(self, operation: str) -> str | None: return self._current_stage_by_operation.get(operation) + def current_risk(self, operation: str) -> str | None: + return self._current_risk_by_operation.get(operation) + def stage(self, operation: str, stage: str) -> None: self._current_stage_by_operation[operation] = stage fields: dict[str, object] = {"stage": stage} policy = stage_policy(operation, stage) if policy is not None: fields.update(policy.to_jsonable()) + risk = fields.get("risk") + if isinstance(risk, str): + self._current_risk_by_operation[operation] = risk self.emit(AppEvent("stage", operation, fields)) def log(self, operation: str, message: str, *, level: str = "info") -> None: diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py index e4af4d74..d8b88cc5 100644 --- a/src/timecapsulesmb/app/ops/__init__.py +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -40,3 +40,14 @@ "validate-install": validate_install_operation, "version-check": version_check_operation, } + + +TELEMETRY_OPERATIONS = frozenset({ + "activate", + "configure", + "deploy", + "doctor", + "fsck", + "repair-xattrs", + "uninstall", +}) diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py index 92695ec7..7a7d2b53 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -4,12 +4,23 @@ from collections.abc import Callable from timecapsulesmb.app.events import EventSink, redact -from timecapsulesmb.app.ops import OPERATIONS +from timecapsulesmb.app.ops import OPERATIONS, TELEMETRY_OPERATIONS from timecapsulesmb.app.confirmations import AppConfirmationRequired from timecapsulesmb.app.requests import parse_api_request from timecapsulesmb.app.recovery import recovery_for from timecapsulesmb.core.config import ConfigError -from timecapsulesmb.services.app import AppOperationError, OperationResult +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.app import AppOperationError, OperationResult, config_path +from timecapsulesmb.services.runtime import load_optional_env_config +from timecapsulesmb.telemetry import TelemetryClient +from timecapsulesmb.telemetry.operation import ( + OperationTelemetrySession, + client_from_environment, + confirmation_details, + telemetry_details_from_payload, + telemetry_options_from_params, +) from timecapsulesmb.transport.errors import TransportError @@ -40,6 +51,9 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: recovery=recovery_for(operation, "unknown_operation"), ) return 1 + telemetry_session = _api_telemetry_session(operation, params) + if telemetry_session is not None: + telemetry_session.start() try: result = handler(params, sink) except AppConfirmationRequired as exc: @@ -50,6 +64,14 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: details=exc.confirmation.to_jsonable(), recovery=recovery_for(operation, exc.code, stage=sink.current_stage(operation)), ) + _finish_api_telemetry( + telemetry_session, + sink, + operation, + result="confirmation_required", + details=confirmation_details(exc.confirmation), + risk=exc.confirmation.risk, + ) return 1 except AppOperationError as exc: recovery = exc.recovery or recovery_for(operation, exc.code, stage=sink.current_stage(operation)) @@ -60,6 +82,7 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: debug=redact(exc.debug) if exc.debug is not None else None, recovery=recovery, ) + _finish_api_telemetry(telemetry_session, sink, operation, result="failure", error=str(exc)) return 1 except ConfigError as exc: sink.error( @@ -68,6 +91,7 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: code="config_error", recovery=recovery_for(operation, "config_error", stage=sink.current_stage(operation)), ) + _finish_api_telemetry(telemetry_session, sink, operation, result="failure", error=str(exc)) return 1 except TransportError as exc: sink.error( @@ -76,17 +100,75 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: code="remote_error", recovery=recovery_for(operation, "remote_error", stage=sink.current_stage(operation)), ) + _finish_api_telemetry(telemetry_session, sink, operation, result="failure", error=str(exc)) return 1 except (SystemExit, KeyboardInterrupt): raise except Exception as exc: + message = f"{type(exc).__name__}: {exc}" sink.error( operation, - f"{type(exc).__name__}: {exc}", + message, 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)), ) + _finish_api_telemetry(telemetry_session, sink, operation, result="failure", error=message) return 1 sink.result(operation, ok=result.ok, payload=result.payload) + _finish_api_telemetry( + telemetry_session, + sink, + operation, + result="success" if result.ok else "failure", + error=_payload_error(result.payload) if not result.ok else None, + details=telemetry_details_from_payload(operation, params, result.payload), + ) return 0 if result.ok else 1 + + +def _api_telemetry_session(operation: str, params: dict[str, object]) -> OperationTelemetrySession | None: + if operation not in TELEMETRY_OPERATIONS: + return None + try: + requested_config_path = config_path(params) + app_paths = resolve_app_paths(config_path=requested_config_path) + ensure_install_id(app_paths.bootstrap_path) + config = load_optional_env_config(env_path=requested_config_path) + telemetry = TelemetryClient.from_config(config, bootstrap_path=app_paths.bootstrap_path) + return OperationTelemetrySession( + telemetry, + operation, + entrypoint="api", + client=client_from_environment(entrypoint="api"), + options=telemetry_options_from_params(params), + ) + except Exception: + return None + + +def _finish_api_telemetry( + session: OperationTelemetrySession | None, + sink: EventSink, + operation: str, + *, + result: str, + error: object | None = None, + details: dict[str, object] | None = None, + risk: str | None = None, +) -> None: + if session is None: + return + session.finish( + result=result, + error=error, + stage=sink.current_stage(operation), + risk=risk or sink.current_risk(operation), + details=details, + ) + + +def _payload_error(payload: object | None) -> object | None: + if not isinstance(payload, dict): + return "operation returned an unsuccessful result" + return payload.get("error") or payload.get("summary") or "operation returned an unsuccessful result" diff --git a/src/timecapsulesmb/cli/context.py b/src/timecapsulesmb/cli/context.py index 0932be45..a2b17ad1 100644 --- a/src/timecapsulesmb/cli/context.py +++ b/src/timecapsulesmb/cli/context.py @@ -1,6 +1,5 @@ from __future__ import annotations -import time import threading import uuid from collections.abc import Mapping @@ -21,6 +20,12 @@ ) from timecapsulesmb.telemetry import build_device_os_version from timecapsulesmb.telemetry.debug import debug_summary, render_debug_mapping +from timecapsulesmb.telemetry.operation import ( + OperationTelemetrySession, + client_from_environment, + telemetry_details_from_payload, + telemetry_options_from_args, +) from timecapsulesmb.transport.errors import TransportError if TYPE_CHECKING: @@ -137,7 +142,6 @@ def __init__( self.config = config self.args = args self.finished_event = finished_event - self.start_time = time.monotonic() self.finished = False self.command_id = str(uuid.uuid4()) self.result = "failure" @@ -152,7 +156,17 @@ def __init__( self.compatibility: DeviceCompatibility | None = None self._optional_airport_identity_thread: threading.Thread | None = None self._optional_airport_identity: tuple[str | None, str | None] | None = None - self._emit_telemetry(started_event, command_id=self.command_id, **fields) + self.telemetry_session = OperationTelemetrySession( + telemetry, + command_name, + entrypoint="cli", + client=client_from_environment(entrypoint="cli"), + started_event=started_event, + finished_event=finished_event, + operation_id=self.command_id, + options=telemetry_options_from_args(args), + ) + self.telemetry_session.start(**fields) def __enter__(self) -> "CommandContext": return self @@ -285,12 +299,6 @@ def build_error(self) -> str | None: ), ]) - def _emit_telemetry(self, event: str, **fields: object) -> None: - try: - self.telemetry.emit(event, **fields) - except Exception: - pass - def confirm_or_fail( self, prompt_text: str, @@ -489,19 +497,26 @@ def finish(self, *, result: str, **fields: object) -> None: self.harvest_optional_airport_identity_probe(timeout_seconds=OPTIONAL_IDENTITY_PROBE_FINISH_TIMEOUT_SECONDS) emit_fields = dict(self.finish_fields) emit_fields.update(fields) - duration_sec = round(time.monotonic() - self.start_time, 3) try: error = None if result == "success" else self.build_error() except Exception as exc: error = f"{self.command_name} failed, and debug context rendering also failed: {type(exc).__name__}: {exc}" if result != "success" and error is None: error = f"{self.command_name} failed without additional details." - self._emit_telemetry( - self.finished_event, - synchronous=True, - command_id=self.command_id, + if self.args is None: + params: Mapping[str, object] = {} + elif isinstance(self.args, Mapping): + params = self.args + else: + try: + params = vars(self.args) + except TypeError: + params = {} + details = telemetry_details_from_payload(self.command_name, params, emit_fields) + self.telemetry_session.finish( result=result, - duration_sec=duration_sec, error=error, + stage=self.debug_stage, + details=details, **emit_fields, ) diff --git a/src/timecapsulesmb/telemetry/__init__.py b/src/timecapsulesmb/telemetry/__init__.py index 1de0a513..8c86a3f4 100644 --- a/src/timecapsulesmb/telemetry/__init__.py +++ b/src/timecapsulesmb/telemetry/__init__.py @@ -17,7 +17,7 @@ from timecapsulesmb.identity import load_install_identity -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 DEFAULT_TELEMETRY_URL = "https://timecapsulesmb.jamesyc.com/v1/events" TELEMETRY_URL_ENV = "TCAPSULE_TELEMETRY_URL" TELEMETRY_TOKEN_ENV = "TCAPSULE_TELEMETRY_TOKEN" @@ -75,10 +75,26 @@ def from_config( ) return cls(endpoint=endpoint, token=token, context=context, enabled=identity.telemetry_enabled) - def emit(self, event: str, *, synchronous: bool = False, **fields: object) -> None: + def emit( + self, + event: str, + *, + synchronous: bool = False, + operation: str | None = None, + phase: str | None = None, + operation_id: str | None = None, + entrypoint: str | None = None, + client: str | None = None, + options: dict[str, object] | None = None, + details: dict[str, object] | None = None, + **fields: object, + ) -> None: if not self.enabled or self.context is None: return try: + inferred_operation, inferred_phase = infer_operation_phase(event) + operation = operation or inferred_operation + phase = phase or inferred_phase payload: dict[str, object] = { "schema_version": SCHEMA_VERSION, "event": event, @@ -91,6 +107,16 @@ def emit(self, event: str, *, synchronous: bool = False, **fields: object) -> No "host_os": self.context.host_os, "host_os_version": self.context.host_os_version, } + if operation: + payload["operation"] = operation + if phase: + payload["phase"] = phase + if operation_id: + payload["operation_id"] = operation_id + if entrypoint: + payload["entrypoint"] = entrypoint + if client: + payload["client"] = client if self.context.configure_id: payload["configure_id"] = self.context.configure_id if self.context.device_model: @@ -99,6 +125,10 @@ def emit(self, event: str, *, synchronous: bool = False, **fields: object) -> No payload["device_syap"] = self.context.device_syap if self.context.nbns_enabled is not None: payload["nbns_enabled"] = self.context.nbns_enabled + if options is not None: + payload["options"] = options + if details is not None: + payload["details"] = details for key, value in fields.items(): if value is not None: payload[key] = value @@ -183,6 +213,14 @@ def run_text_command(command: list[str]) -> str | None: return value or None +def infer_operation_phase(event: str) -> tuple[str | None, str | None]: + if event.endswith("_started"): + return event.removesuffix("_started").replace("_", "-"), "started" + if event.endswith("_finished"): + return event.removesuffix("_finished").replace("_", "-"), "finished" + return None, None + + def parse_os_release() -> dict[str, str]: path = Path("/etc/os-release") if not path.exists(): diff --git a/src/timecapsulesmb/telemetry/operation.py b/src/timecapsulesmb/telemetry/operation.py new file mode 100644 index 00000000..0d366d18 --- /dev/null +++ b/src/timecapsulesmb/telemetry/operation.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import os +import time +import uuid +from collections.abc import Mapping +from dataclasses import asdict, is_dataclass +from enum import Enum +from pathlib import Path + +from timecapsulesmb.telemetry import TelemetryClient + + +OPTION_KEYS = frozenset({ + "allow_unsupported", + "any_protocol", + "ata_idle_seconds", + "ata_standby", + "bonjour_timeout", + "debug_logging", + "dry_run", + "fix_permissions", + "include_hidden", + "include_time_machine", + "internal_share_use_disk_root", + "list_volumes", + "max_depth", + "mount_wait", + "nbns_enabled", + "no_reboot", + "no_wait", + "recursive", + "skip_bonjour", + "skip_smb", + "skip_ssh", + "verbose", + "yes", +}) +DETAIL_KEYS_BY_OPERATION = { + "activate": ("already_active", "message", "summary"), + "configure": ("configure_id", "ssh_authenticated", "device_model", "device_syap", "summary"), + "deploy": ( + "message", + "netbsd4", + "payload_dir", + "payload_family", + "reboot_requested", + "rebooted", + "requires_reboot", + "summary", + "verified", + "waited", + ), + "doctor": ("counts", "error", "fatal", "summary"), + "repair-xattrs": ( + "error", + "finding_count", + "repairable_count", + "returncode", + "root", + "summary_text", + "telemetry_result", + ), + "uninstall": ("reboot_requested", "rebooted", "requires_reboot", "summary", "verified", "waited"), +} +SENSITIVE_KEY_PARTS = ("credentials", "password", "secret", "token", "key") +RESERVED_EVENT_FIELD_KEYS = frozenset({ + "schema_version", + "event", + "event_id", + "occurred_at", + "operation", + "phase", + "operation_id", + "entrypoint", + "client", + "options", + "details", + "command_id", +}) + + +class OperationTelemetrySession: + def __init__( + self, + telemetry: TelemetryClient, + operation: str, + *, + entrypoint: str, + client: str, + started_event: str | None = None, + finished_event: str | None = None, + operation_id: str | None = None, + options: Mapping[str, object] | None = None, + ) -> None: + self.telemetry = telemetry + self.operation = operation + self.entrypoint = entrypoint + self.client = client + self.started_event = started_event or legacy_event_name(operation, "started") + self.finished_event = finished_event or legacy_event_name(operation, "finished") + self.operation_id = operation_id or str(uuid.uuid4()) + self.options = dict(options or {}) + self.start_time = time.monotonic() + + def start(self, **fields: object) -> None: + self._emit( + self.started_event, + phase="started", + options=self.options or None, + **fields, + ) + + def finish( + self, + *, + result: str, + error: object | None = None, + stage: str | None = None, + risk: str | None = None, + options: Mapping[str, object] | None = None, + details: Mapping[str, object] | None = None, + **fields: object, + ) -> None: + emit_options = dict(options or self.options) + duration_sec = round(time.monotonic() - self.start_time, 3) + self._emit( + self.finished_event, + synchronous=True, + phase="finished", + result=result, + duration_sec=duration_sec, + error=error, + stage=stage, + risk=risk, + options=emit_options or None, + details=dict(details or {}) or None, + **fields, + ) + + def _emit(self, event: str, *, phase: str, **fields: object) -> None: + try: + synchronous = bool(fields.pop("synchronous", False)) + options = fields.pop("options", None) + details = fields.pop("details", None) + emit_fields = _avoid_reserved_field_collisions(fields) + self.telemetry.emit( + event, + synchronous=synchronous, + operation=self.operation, + phase=phase, + operation_id=self.operation_id, + entrypoint=self.entrypoint, + client=self.client, + options=options if isinstance(options, dict) else None, + details=details if isinstance(details, dict) else None, + # Retain the old field during schema v4 rollout for existing dashboards/queries. + command_id=self.operation_id, + **emit_fields, + ) + except Exception: + pass + + +def legacy_event_name(operation: str, phase: str) -> str: + return f"{operation.replace('-', '_')}_{phase}" + + +def client_from_environment(*, entrypoint: str) -> str: + value = os.getenv("TCAPSULE_CLIENT", "").strip() + if value: + return value + return "terminal" if entrypoint == "cli" else "terminal" + + +def telemetry_options_from_params(params: Mapping[str, object]) -> dict[str, object]: + options: dict[str, object] = {} + for key in sorted(OPTION_KEYS): + if key in params: + value = params.get(key) + if value is not None: + options[key] = _jsonable(value) + return options + + +def telemetry_options_from_args(args: object | None) -> dict[str, object]: + if args is None: + return {} + if isinstance(args, Mapping): + return telemetry_options_from_params(args) + try: + values = vars(args) + except TypeError: + return {} + return telemetry_options_from_params(values) + + +def telemetry_details_from_payload( + operation: str, + params: Mapping[str, object], + payload: object | None, +) -> dict[str, object]: + details: dict[str, object] = {} + if operation == "fsck": + _copy_param(params, details, "volume") + if isinstance(payload, Mapping): + target = payload.get("target") + if isinstance(target, Mapping): + _copy_payload_key(target, details, "name", to_key="volume") + _copy_payload_key(target, details, "device", to_key="fsck_device") + _copy_payload_key(target, details, "mountpoint", to_key="fsck_mountpoint") + _copy_payload_key(payload, details, "device", to_key="fsck_device") + _copy_payload_key(payload, details, "fsck_device") + _copy_payload_key(payload, details, "mountpoint", to_key="fsck_mountpoint") + _copy_payload_key(payload, details, "fsck_mountpoint") + _copy_payload_key(payload, details, "reboot_was_attempted", to_key="reboot_requested") + _copy_payload_key(payload, details, "device_came_back_after_reboot", to_key="verified") + for key in ("returncode", "reboot_requested", "waited", "verified", "summary"): + _copy_payload_key(payload, details, key) + return details + + if isinstance(payload, Mapping): + for key in DETAIL_KEYS_BY_OPERATION.get(operation, ()): + _copy_payload_key(payload, details, key) + compatibility = payload.get("compatibility") + if isinstance(compatibility, Mapping): + _copy_payload_key(compatibility, details, "payload_family", to_key="device_family") + os_name = compatibility.get("os_name") + os_release = compatibility.get("os_release") + arch = compatibility.get("arch") + if os_name and os_release and arch: + details["device_os_version"] = f"{os_name} {os_release} ({arch})" + return details + + +def _avoid_reserved_field_collisions(fields: Mapping[str, object]) -> dict[str, object]: + output: dict[str, object] = {} + for key, value in fields.items(): + if key in RESERVED_EVENT_FIELD_KEYS: + output[f"legacy_{key}"] = value + else: + output[key] = value + return output + + +def confirmation_details(confirmation: object) -> dict[str, object]: + to_jsonable = getattr(confirmation, "to_jsonable", None) + if callable(to_jsonable): + value = to_jsonable() + else: + value = confirmation + if not isinstance(value, Mapping): + return {} + details = _jsonable(value) + return details if isinstance(details, dict) else {} + + +def _copy_param(source: Mapping[str, object], target: dict[str, object], key: str, *, to_key: str | None = None) -> None: + value = source.get(key) + if value not in (None, ""): + target[to_key or key] = _jsonable(value) + + +def _copy_payload_key(source: Mapping[str, object], target: dict[str, object], key: str, *, to_key: str | None = None) -> None: + value = source.get(key) + if value is not None: + target[to_key or key] = _jsonable(value) + + +def _jsonable(value: object) -> object: + if is_dataclass(value): + return _jsonable(asdict(value)) + if isinstance(value, Enum): + return _jsonable(value.value) + if isinstance(value, Path): + return str(value) + if isinstance(value, Mapping): + output: dict[str, object] = {} + for key, item in value.items(): + key_text = str(key) + if any(part in key_text.lower() for part in SENSITIVE_KEY_PARTS): + continue + output[key_text] = _jsonable(item) + return output + if isinstance(value, (list, tuple, set)): + return [_jsonable(item) for item in value] + return value diff --git a/tests/test_app_api.py b/tests/test_app_api.py index d9d7ed64..aa5b87fb 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -4,6 +4,7 @@ from enum import Enum import io import json +import os import sys import tempfile import unittest @@ -19,6 +20,7 @@ sys.path.insert(0, str(SRC_ROOT)) from timecapsulesmb.app.events import AppEvent, EventSink +from timecapsulesmb.app.confirmations import build_confirmation from timecapsulesmb import repair_xattrs as repair_xattrs_domain from timecapsulesmb.app import contracts, helper, service from timecapsulesmb.cli.version_check import VersionCheckResult @@ -459,6 +461,117 @@ def fail(_params, _sink): self.assertIn("Traceback", error["debug"]["traceback"]) self.assertIn("RuntimeError: boom", error["debug"]["traceback"]) + def test_dispatcher_emits_api_operation_telemetry(self) -> None: + collector = CollectingSink() + telemetry = mock.Mock() + + def run_fsck(params, sink): + sink.stage("fsck", "run_fsck") + return service.OperationResult(True, { + "device": "/dev/dk2", + "mountpoint": "/Volumes/Data", + "returncode": 0, + "reboot_requested": True, + "waited": True, + "verified": True, + }) + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch.dict(os.environ, {"TCAPSULE_CLIENT": "macos_gui"}, clear=False): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + with mock.patch("timecapsulesmb.app.service.TelemetryClient.from_config", return_value=telemetry): + rc = service.run_api_request( + { + "operation": "fsck", + "params": { + "volume": "Data", + "dry_run": False, + "no_reboot": False, + "no_wait": False, + "mount_wait": 30, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(telemetry.emit.call_count, 2) + started = telemetry.emit.call_args_list[0] + finished = telemetry.emit.call_args_list[1] + self.assertEqual(started.args, ("fsck_started",)) + self.assertEqual(started.kwargs["operation"], "fsck") + self.assertEqual(started.kwargs["phase"], "started") + self.assertEqual(started.kwargs["entrypoint"], "api") + self.assertEqual(started.kwargs["client"], "macos_gui") + self.assertEqual(started.kwargs["options"], { + "dry_run": False, + "mount_wait": 30, + "no_reboot": False, + "no_wait": False, + }) + self.assertEqual(finished.args, ("fsck_finished",)) + self.assertEqual(finished.kwargs["phase"], "finished") + self.assertEqual(finished.kwargs["operation_id"], started.kwargs["operation_id"]) + self.assertEqual(finished.kwargs["result"], "success") + self.assertEqual(finished.kwargs["stage"], "run_fsck") + self.assertEqual(finished.kwargs["risk"], "destructive") + self.assertEqual(finished.kwargs["details"]["volume"], "Data") + self.assertEqual(finished.kwargs["details"]["fsck_device"], "/dev/dk2") + self.assertEqual(finished.kwargs["details"]["fsck_mountpoint"], "/Volumes/Data") + self.assertEqual(finished.kwargs["details"]["returncode"], 0) + self.assertTrue(finished.kwargs["details"]["reboot_requested"]) + self.assertTrue(finished.kwargs["details"]["waited"]) + self.assertTrue(finished.kwargs["details"]["verified"]) + + def test_dispatcher_emits_confirmation_required_telemetry(self) -> None: + collector = CollectingSink() + telemetry = mock.Mock() + + def run_fsck(params, sink): + sink.stage("fsck", "select_fsck_volume") + raise service.AppConfirmationRequired(build_confirmation( + operation="fsck", + params=params, + title="Confirm fsck", + message="Run fsck on the selected HFS volume and reboot the device?", + action_title="Run fsck", + risk="destructive", + summary="Filesystem check and repair", + context={"volume": params.get("volume")}, + presentation_id="fsck.reboot", + presentation_values={"volume": params.get("volume")}, + )) + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + with mock.patch("timecapsulesmb.app.service.TelemetryClient.from_config", return_value=telemetry): + rc = service.run_api_request( + {"operation": "fsck", "params": {"volume": "Data"}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(telemetry.emit.call_count, 2) + finished_kwargs = telemetry.emit.call_args_list[1].kwargs + self.assertEqual(finished_kwargs["result"], "confirmation_required") + self.assertIsNone(finished_kwargs["error"]) + self.assertEqual(finished_kwargs["risk"], "destructive") + self.assertEqual(finished_kwargs["details"]["presentation_id"], "fsck.reboot") + self.assertEqual(finished_kwargs["details"]["presentation_values"]["volume"], "Data") + + def test_dispatcher_does_not_emit_readiness_operation_telemetry(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.service.TelemetryClient.from_config") as telemetry_factory: + rc = service.run_api_request({"operation": "paths", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + telemetry_factory.assert_not_called() + def test_discover_operation_returns_snapshot_payload(self) -> None: collector = CollectingSink() snapshot = BonjourDiscoverySnapshot( diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 2e835269..2b6d27b5 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -42,7 +42,7 @@ def telemetry_client_from_values( class TelemetryTests(unittest.TestCase): - def test_emit_builds_schema_v3_payload_without_stale_config_identity(self) -> None: + def test_emit_builds_schema_v4_payload_without_stale_config_identity(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" bootstrap_path.write_text("INSTALL_ID=test-install\n") @@ -59,8 +59,10 @@ def test_emit_builds_schema_v3_payload_without_stale_config_identity(self) -> No with mock.patch.object(client, "_dispatch_payload_async") as dispatch_mock: client.emit("deploy_started") payload = dispatch_mock.call_args.args[0] - self.assertEqual(payload["schema_version"], 3) + self.assertEqual(payload["schema_version"], 4) self.assertEqual(payload["event"], "deploy_started") + self.assertEqual(payload["operation"], "deploy") + self.assertEqual(payload["phase"], "started") self.assertEqual(payload["install_id"], "test-install") self.assertEqual(payload["configure_id"], "config-id") self.assertNotIn("device_model", payload) @@ -153,9 +155,53 @@ def test_command_context_reuses_command_id_for_started_and_finished_events(self) finished_payload = send_mock.call_args.args[0] self.assertIn("command_id", started_payload) self.assertEqual(started_payload["command_id"], finished_payload["command_id"]) + self.assertEqual(started_payload["operation_id"], finished_payload["operation_id"]) + self.assertEqual(started_payload["operation"], "deploy") + self.assertEqual(started_payload["phase"], "started") + self.assertEqual(started_payload["entrypoint"], "cli") + self.assertEqual(started_payload["client"], "terminal") self.assertEqual(finished_payload["event"], "deploy_finished") + self.assertEqual(finished_payload["phase"], "finished") self.assertEqual(finished_payload["result"], "success") + def test_command_context_records_normalized_options_and_details(self) -> None: + telemetry = mock.Mock() + args = SimpleNamespace(dry_run=False, no_reboot=False, no_wait=True, volume="Data", password="secret") + + command = CommandContext(telemetry, "fsck", "fsck_started", "fsck_finished", args=args) + command.update_fields( + fsck_device="/dev/dk2", + fsck_mountpoint="/Volumes/Data", + reboot_was_attempted=True, + device_came_back_after_reboot=False, + ) + command.finish(result="success") + + started_kwargs = telemetry.emit.call_args_list[0].kwargs + finished_kwargs = telemetry.emit.call_args_list[1].kwargs + self.assertEqual(started_kwargs["options"], { + "dry_run": False, + "no_reboot": False, + "no_wait": True, + }) + self.assertNotIn("password", started_kwargs["options"]) + self.assertEqual(finished_kwargs["details"]["volume"], "Data") + self.assertEqual(finished_kwargs["details"]["fsck_device"], "/dev/dk2") + self.assertEqual(finished_kwargs["details"]["fsck_mountpoint"], "/Volumes/Data") + self.assertTrue(finished_kwargs["details"]["reboot_requested"]) + self.assertFalse(finished_kwargs["details"]["verified"]) + + def test_operation_telemetry_renames_reserved_legacy_fields(self) -> None: + telemetry = mock.Mock() + + command = CommandContext(telemetry, "flash", "flash_started", "flash_finished") + command.update_fields(operation="read") + command.finish(result="success") + + finished_kwargs = telemetry.emit.call_args_list[1].kwargs + self.assertEqual(finished_kwargs["operation"], "flash") + self.assertEqual(finished_kwargs["legacy_operation"], "read") + def test_command_context_ignores_started_telemetry_exception(self) -> None: telemetry = mock.Mock() telemetry.emit.side_effect = RuntimeError("telemetry unavailable") From 909e05928f89c56048eb04e700e68a9a3384abce Mon Sep 17 00:00:00 2001 From: James Chang Date: Fri, 22 May 2026 05:46:23 -0700 Subject: [PATCH 035/129] Fix mdns advertiser degraded IPv4 takeover recovery and diagnostics --- bin/mdns-netbsd4be/mdns-advertiser | Bin 245456 -> 250540 bytes bin/mdns-netbsd4le/mdns-advertiser | Bin 246896 -> 252016 bytes bin/mdns/mdns-advertiser | Bin 305656 -> 310672 bytes build/mdns-advertiser.c | 570 ++++++++++++++++-- .../assets/artifact-manifest.json | 6 +- src/timecapsulesmb/cli/doctor.py | 160 ++++- src/timecapsulesmb/discovery/bonjour.py | 94 ++- tests/test_cli.py | 159 ++++- tests/test_deploy_modules.py | 177 ++++++ tests/test_discovery.py | 64 +- 10 files changed, 1102 insertions(+), 128 deletions(-) diff --git a/bin/mdns-netbsd4be/mdns-advertiser b/bin/mdns-netbsd4be/mdns-advertiser index 8987f16c3a10cecf037b372328d6f88979d8e961..23bf7d15920d598df5b92982b367424d2f05b43f 100755 GIT binary patch delta 40427 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

!8xzF#umPeRJaR=e(=C_x+Vt{rPpZuQ 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%0DW}Nf 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 Ik{*eF0QT`VfdBvi diff --git a/bin/mdns-netbsd4le/mdns-advertiser b/bin/mdns-netbsd4le/mdns-advertiser index 57e0607ae9803be799387399cb9139be8fc01f85..be639905743a192bb78a2498f2d201321e187700 100755 GIT binary patch delta 39728 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 delta 34795 zcmc$HdstM}`v2N{283}W?&5$jA}T84NTi4;Bch@pj^G82j0{Z?jmr93$C^=5k<^1O zbj%x*nHrgS%TcoqrInSLrIvNb=Hdqy+^5rod)7H+S;<^;<3N% z|2|9*ya)Zo?m~!1!z!ZN+MYUMxu8dr+Gw7xSs}Rj+nQ$u7thy@7oOtVw8swC`ZWt; z`Qd}lbhuv-%K4>$GvfT$`QE^uf`?xS{F88kPteU3cJnuMt>U6lyuM?Z|C(d01&;=O z&y+=@0$8U8hud_q-<87t4lgr1+=9j7*6z#muwaMxPsdp6T}Fpj%N%ZFaGhi<;p;lZ zi)oAb;ZDioCu81UuYR`P=*X^q`b`JkLAmQM_e+F$9#Jqp6|%7UjWw!d+pa?I<4=GB$MHQ4j*!n9eXOWVVo`K3AWhRszRLcTT zwvPvPe%4^NpmmAGLt+z*BRwP`5w)MYkMHdqDb_CL?{>}-yIZ&}YOMIkB0f24TF+^V zSS#wZ2&QvVu&Yk`ysfPz>TIs30^-_&ftn6$SncA2pG6r2!)JeDtpcm?Fy^?M%8`00 zg=XmtS>gxJ^L)bx;;KI#G(?vP;($N#*dEpC_~{^K>i$v9o~zQ%tsri(p(<^#LcCr^EiwYGt< zB*v=8j?eY@p%HQHseIPD8^qFcqA}MK1@7d(#)IN#i2umpkH)9_p9Ejh4j()ae?}uN zKE#*w^oTbP@>#tOXrm9YR_?ev=7qFGL9`v_>AimuYY*~)ee#1IdH{SdTVUj@KKT$| z*JrvIcaVSI=a-<*4p51rFXs;MU;54vw~pk~la`FVf|Bg8Xh{fO;Sz%vdy-kMM+6VO z?eewA&p^Hw`Cqlm4?un&wf^S^c%Oa+V%SdypXv9LAU^&Cf1>|kar6@hlT8JJ`2FL2 z$$)$@cnbe?z%+5g{)5RWI#E2a?_hfBF;U#T_~1_i%LHK-e<1CCtNT9CT4C{YFwYDo z)go?hdDTb%MDIO}ZK6C&Xr1H-iWX*}3{36_iBo=tn*XSp4<9M#C#w17YJPXZZO-<@ zv5DS92q>q71hkB24h|GQewB|M94~yx=M9eTa`FeV3X2zr3>!_+l0Q0G-F2zwP5%7g z|B1{6X_+X6?NyUMr~*Ug zu!8HaZf!0KXY3Rt?gBIet}o_ehfEhAdydx+nbGs&Uf4w5GFd0d8@6H4izc#Go%&2# zv~VI%$=Dfm6OHvtG|ULdSaR^2jO9Y;Kr|;MB;E`FktuumGs6ri1*4(Jz(AuJkLM0Z z9XHxq>3thgdXGm_&??u7+si)~7BBv6=GTU$i&OUUKAGv6^JXZ#&ry}R6Ie7`%}Z1B z0+9EhnwO~NK|>AGXE6S7SVYjTpm`$bEd^98;qPY77yqY_4>V5~yUpMm%<<_jyh0{t zaT9mR=MaUEMITEcIyX7Ie#Yf4OaX4elqD%X!=?C)CH#ByF!91G2UCVWC5TVF!uMuH ziWRRMd^c;IAkHt~)3VFN>wEaS*~d~63Y6LA%J<~<9qxI1SnH0EI;qWABKx?sY}TsL zFY}Pen=yrDUgrl!){E79`1Dbi{C8srCl~Xq(S5|llllD7k>W2e^W~#Ai3LeKK4-G{ zd?tS?XIAWrOb{GwA%t%Q7#(kQFgf13_0P5s$;ow{%x~pn1?eY)4u{PZ2aizoGS45A zCH~ZxuNxCD7G(0nV`9aoN&K@hS?OQ(Wi2)^>gXZ>;C@$MzJjoB4*ZJ!9u4DGc#B>g(v} z^l=SKT&iI)OZk^$`|zmTY%y>#pPm~pUZ231&6d*uX#wKb9NY#{|_P z0ugTLeD3dfmZEw_jQg+jjKO}9#l^fVYv2|pYhVmn2K;_TnU-Ciqh)F6wo`+{ef?`? zN?&kxoQFaR(d=+*Y?y^h;8`m+OZ#6|bb^^|Qb5CIiFAe(AMAG9f>$@s{<|{U?V@@x zd}gVUsvNnVG{_o^u)5)j>m4X0<6;(@R03loQ#cC62sUn(!Uxnzj1|b%q#s@R|Zl=2<|DU`P(An5kOo zf)1GZxj)1e0$j`0!Q`0gq?`t2K!jU!UxOorkW(kA=pb9|AfREjH12nutOOU(2_)!q zVkaS@JU|hE#8!%Q_ETC`4mgkJm`Xk?KVLN0^TYYi2n+Z<A z^Q7~qCkzpe@uL&^`Y%KjdU7dmnJ`&c!G{+-B+TP46+9?TyvBnH3u2dCQ(T=-zbR(5 zjSFB=qXSs&DE>@gIt8}J3e)|rqhCM%&)(g6%)|su_m>#|*dD=)CPqc-Z$X*BDRMqW zQrMTaHdj>gB4*S`&kX0cCK(JCWG4^>uWW5DS%xqZ zKr%Q)I0yD5cRPk!7@>S%9o%$FzS_?gwVz8Fe9dHonEn#4NAlwk{_f=1$gaRk#OTl< zQSCBwIR9m`AwC2}gipla+jp&9u5_KfoDZCmES?(5XHAJ0PY&VLQw&yi9TN-i5!Wo} z*a97EM*JEC{5Oc8V27I`IA*Sq{nCx^(1}F}n*4@ilA^Y-GSbg*v<(N2i)in27q#fz zp%T!DRCXC(^t-~?1TR(syn9h^@sr&=zo_TXl|zVw@SHGst!XpVxzG#F>Qc?oQUgdK z?tX6mtxGMs+=DLnpv!u`zbMY|C@}Pf@^u8ki6(?c-~@x2mk;4BMTQnao*EZlNfA7d5G#FpMAA!Kff*67dj&6>+DB|$!8c4zj_g$q->tWJ zXAt3Nu?MbePci>;YP?vwi{F|WD{$U*T3;cXkD2zEFqgkEt+UXLUzpY_w6nZYuy~;k z_2l)>mGPi^Vn;6}pSVKr&(7u3?lq+Uj70&~8n=U&%b@To zfPxqS0o!#vXW`jcg?Sxm5>giF$5N~K-g_qt$GKl|X6!riO2g&#IT4=#&8Q4JnNd7V z*u~!{HU@$XJCJw0ihol)Ojyl(+~@5XIhVC&G(<~!5ashe#4i^rCY5Vh0^rvwo;*E8 z%x^h3WBQj^{FL!wCHdmxWqb{i2g>-RlF4Fj86Wt-WbqzkB8e;G-yjJ@%^8!$ALj54 zNWPlGuOWGF4j=Vky6_WU{9u?hW~GKDcgMhxW&jHCoQHG<(qg2KA}vR{9qA&ZZzElW z)Pr;j(sfApAUzInUC^+NHVxYX*bO)YXaJlBTm)POTnGFLpdkh<*6^^>9>PUFxHMCE zgg;q2T8QIsmEJ2jdDoeD3&niG%pt;3zItYN!OZv19DxPO&!@Gl?g6LdkX zNugHlY#us0UT3OSsjKG0X3r2F;?K_>By{0l%)VdT_dSoD8Ow|2gbS1Syg9kz<1g@I za}1#m01LSxnw*niMD-K+H*@+31w5oIDMS@|2GAA*7FP58vh8B87x?#O8-yEt<=klT z)ra`bxv_@tw?jwaAV{6Rqh~XgW_Z53onM-poN|t)dyLAbB|B6?uObb8R?(JHq&0w4 zz#$BvJXv^?&nq7-%;2w;=ZbfI&&7G4303@yd4q%`9x{K5Sh$^+%}*CK&3xzlKEliV zllhs#dLHs{oKV6CJUl++tE<$j#k&i}MC1FBuSHn{-~aH}!Vtc;VuX;uFI4mw8oA#i zBZO&u%p>jA$L<9wUp@A)u!E0VFd{JETLt^) zeDz~deE)(tVLSh1!TrLqg99EPA_zkc&U+$S99Dq|XAefSU^N!LYZU7wt0aqTKz_n1 z4XXlN1(1x3P_G71irISiYX0uRHNs*(>B;faD(m0_;Sp%nL0+KuNY+XoA`|a|zS7eJ z@7mza67A9Wb*l7q!+S(frH2Bj1mtzadjQ(`L=a_Ua)*g)_^l_0g^tEjLywt@L7Oa~ zlZ=1SC^5T<*Do5|Cl;kK@R#K4qv@IExOVfV{yByEyytlA)1!1;*+20<8SILfCYT{Gs(hU{@OD`LJpu6 zb0~^sG;fgp!Ubz)pJ%ZY^oP2!3I}bVw8XIW(sQU;6pRs=mAT7^yxMPgnKf2?@&{gR zO%~4#zVpM7Bw(;$Y2Z{#<@Glp~_IV!re1A-x9sF$bEj<`y`;@N@x($YwA`uF^&rSX${fIiw(q~N~<7_@AXn?evcC`3L* zXsyhEj}Bz53svsV2K}Sa>qNQ7$zE*u;S*!5jqC z5ojhL3~)e2E*tA4TFH>nQ&@8dyGuGe`v)t_DGG>QqWI8c_mOjq*nGmJ;OYrn*(JFi zau&pLKOZH^L zTC5a#HCU(UfRAV(MM@x{U^H6L`Io=7wfdY*BVrNL*59v7EO>9v_HZmE^);J${}sc9 zh5V5fcZ*xL@|`Q`Xof3OTP7r9TL4w2ag*YRT;qpdc@ zC^L*5rQzUyDz-X^858K)7%ed~C^fD_WZx0bRg#tfBN+qCWQbbugRp;$k)&cHk(L}q z(DpQJwP!%8_o-Hh+2sXRg1W3q>QLi&zJtT@JeKtJLO=e}bKSG9rYM^>vUhT=mgdXb z3KkIVIERJt2~v`1=n;8ZPrd2L<5L;5Z0FsGmqf9xuD%3qjD*lXY^BbjSqp2KRFjb+<;XZyzaquV6 zmx(xpofG27Pn($)YJxo)RGI>s{)A46?m#HU4fu2{huqiU*Duim5Ufizb(9srhXjkC z+iZJTUDhxMIjbtkL}Rak&o{&AQ~*lg?rJ*9F7QPVM1%1+hGVN1dpYFMV92`g;96>qr81d}T z-O6bK@oWnWWaW2!)RvT<6G2}`&_XP31w9Ji<*3;HIfA0?*cn7*xnY1yaWYpsm@2 z4L3H$?QLr_oT{jk0@3N;v5~_pN~gcz`OZ$=cXW#B#n8Q7ryJ2}B|4=8qZ;6+T~hMI znqaF#po+v8+E+2Egw%kL5>zBYE`boLjK&5R5rUDxo^-UgLF@Z4w86XVw`kpOHr7c& z$cqM(P3?RQ;jhFI)p3WyZ5vXv75{BPrsH{p<$wf0z;+EQ0bD^CPvY?H@roAAH6;#r zcd`X?zr=Qia>+4O5-< zwye)qD6)RKU8gp+(<2atuhV09bc&Gw*h}p?-2oBQKrGOA-4?W?y)Q-94uR?+{%hHV zl!zFH-Ky3KR3bTlt;!ki5%3m;G$6<~T0G(L0y)ikn}3}Yj|~@03N0e!L$(-&HvR7Aq0c7|xOXGu4#kDD zs82=JPN*vOLEIc43hoqmf&TfXF6?DO8!m=Om-!>lkM3zf$402W*60{uG&;JIt0Tj& zK|ZAvF%jS|Y0SBj`{W*A6KC!Aq&KocB+zBlrxw$F&|Kvhv? zA!bDiQ^w#uuS7X|`{WY;WqWLoo1nT1(Y+7bR>B6V!^KdkC|o`obT{&$FGLH?eCi9m z#K#-?k{7mzyoP4J#SdZsJHKZ~_t4#UAR9M~m+j~yF1PayJLZd@xw-#~v0eRX?yy)q zIg8gvV0sRlZC(}%F&Q$!R*4UP@n!M5zw&D@V$bs{9=mgF(9Wpec=kdRe`4n-F(H}1 zxieNAw~=4knIiW8nuqR67Oh|LtX;!&0WZSD=L9q)zR0V0mBrU%6M^_hmPjFxk~-v+ zLXwOZdF1XCVGkd(J5KE3=4HFbifirs@a}lA%Fe&soh*j^mFr$Q9FlMU?Y0FSAO65U zd8tsme2Mq0@6&r4R_8GYKYpQW8gNJragbnV@&ih!6Gdfl4)53e$@f4o=tVY;_qcNChHQ;;j=Oax2LvGfbGx-FxD!LmL$jOY?P1$z~}- zUngb6U;zjXp_KqDSuLHy?ue*iEgitR>0c`K;RbXeopL<^%!j<^3O-^6ttfCV)8ZNeFJStdTVLrmETTOPI5OFSHoMV=ArK=vsGS2e zd?C{Az-Abb#fypBK=hcu zA=u`k8v{uYqKJ{3H}KF`H+4JS1q>{4xLZ65vT^9AjZMY@;N-Be7{wy~(W{-u6bIH} zESQHNiZ!$@N4MJqg-!KeDDNcZz#$ZX*_)Eo`VswBc$TR3*P(tA@3${bcOyW-_PE3+ z?~4^TeZimD7u#f{Pr>C$znTK1mCS4jX@KUs$I}D(0EM zT*KY_I^SbKJL;d}XOh9EcVVzGD?D5s9mU|j(NQ=PUVzdu$u#8>a{D>(Sk{n6qK5C05VdC2O_^#_b%(ic4WKxEfZY6qmdz7F7^4dJfM zl8ZlZpf5JkS09KL`+0c%fn*rS7YBL;eT|je7f@JIwx)|b=%7LDT#0U8OA*fVd9THZ3*3CoYmwL0#^SrLb`xDFz(Cdkm?IOesswZ6dD zAMSpisUcb-B`=*BEiEdjlk&3>!~?6|W%KA8@;nu=$r4yjc0)%g(+rR1>L{5@a%q@S zCa5Ta*@$^Do>5@g52U}r>zl}v)3mGM1DxHv+y~vr0Or{e%n1Dln3pvlMdX&m=Q%S) zV=dq7jJKXfG(~F;46-^jXqhLh7H3DMXlXCv%%He*70LpP&%%n=HB;F(wT#H?0YZio6G0xN>b;~&8$mK3e`G+c1zQuu zRD^|aVst|7@SZ|!BcSf7i+tOWtjM%>RcNtK0mgxg{3jH}t3?zcQDBK}zm~5r^8BNb zV=J)DOfifWN85C8T!YDP2Oy>!wx^N00CXHnH!J91i4Kr{MIIf~z70H$ez?4c=cv*A zz|m3Keqf%xVt^3NgO3GRz1T=g#LXV8_&tO3vpuE#p@R-^%|;7E2m&My;;hwjSDlnd zJGX4HD~IYJe*p3acg*!9Vy;M2^Q!RN7x_tX7#_B~N8)Zo0-htV-JON!7(92yb0(g{ z;}EyT;w1wwt$GEK78Mo%$spuiQS)xZDtRf$%jyjxN^k&J1CMBOxYOoqq(Gz@^F=8Y zpfNh!r-s!@O=z2h_cx$PM4-lavMCVHqG>7Z>bmJ&G+CE=s*FoKf(we>fQtgb!{v%$ zCWm(;8f}0I2i{xhG0hKz^1USmRXE%;fxy28oWYs8;A)VBlGDaG4}unIWRcLd;soG-nvTkLxUMl^S!UfT6+hXJ=&0sO>}th zOd4W3^i>7CgzswCI;lX#Nnw-^=QsM?16V`LxK5+a7_|=Rvab&6*GnoRNjg}Q%QzjV zKlC@8*4quhxdT7oVkC8JL#0%;lE56AtSh-Up zxXP&Uo2u5yMxE|H91XxRHCIeKIRbTUS*phtx;AA%Z( zW(4woMJz)EcJk$;ZVp6i;H$-jMNOhFSuM6s}R0XbDQU{H}BcN_`5MhSIe>@j|1(^w-mJxK&@d^E8bUYuUd;%M_;P7&Lc82E#l8K z81Aw4X01?d57gRI3oz51Vmu-F^2knW*$AmXog1pqGzLUQhtz<$qqh`8DnV`tb<2a_ z3b4Wl$V{b!!B^BSlTbsmNU;J6pM3U<`K7)o3H=+hJX-)F?(UGE(DkJ2zZP%S`?(@$ z5K@EmemyaJ1ncUg%TZty!ZwjPyk7(EuzYtH`7?)+(!F%G>m_GO5l3Lk-tfS>h#Q^j zB1%zCZ5uHu9~#V;za48eQc!83yDzoUL!T>040InR9BB9Ss*`pg7JnC)@WN=$HaWOp zax8V+#oe`?amyZjyV;59#z3aQ7#FTtdnpE>HVaUGB~i)4ICMhEau1PqI7nGFY1m}9 zo|zx=*wpj1FR%#;uD38Y)w>l9*5*N&m~yiQ=Xz!%HN}QWr9G7!&hU+%pK6o^y(uJBcQh_miN z&Y}*8o+9Bf5CwL^z*3NQGfn&JB`p{xFB4UU89^Tn z{=q+BSO9Pn!#0DaZ{Q(bQJI6C$Br_`nz322Jdv+C886uQ{*!%s_^XS}1aKn~S|CCL zqJV*dWr|6YO%CtpTzIGR=LSzYQqzjs;s_Tb`sdF<^PI zcN~a4ri$+4;#|*7h%OO?(`L@a?sZZFo=GHy5Z@`h`!;#TtKy{hJvYH(kZo&gi!~Gu zC3$jLo;^W3=`V5MBosG~_j@8nHB+lM-aL^fU4dkZQZLc=Y{`~laT z$QE*ur$fam{Npz8o0+oZ+5aE z_3gwAiQCAhOqD;<@byjovb#T(S&N-K9ZOZ7v1zHxK1213y%a}9fkusK9Y6P8h&VWi zfAd~XYx#LaM06Fq0_&bq4E+_X6&c$oO~k?vBKG!!RS^jYvvdgS-n`)kr6f6F0ScTCuPEl9s%XpK=uB> zyS(4sIuHHOlmWUoWvPB*@G!V)(^6O4fOAqZ(3Ya>7_9kHf#oE+B9)E;tuNqKH|^*l z_6|D%-aG)crsK(l5I@M1Pef^>+Jz-l9(sKbZ3E!8imPt41z<&TDx6uF@6LVrP7R9! z(B1n}Shn263~$7HDj*L~45$KZ1ndSJ0yF?F0&W1pccCpH36KG>07?N10abu4fIWcY zfYX3Rz|~z=+zrEnemB-mfFwW$AP-Osm<^}^ECkd5HUb=gMgZNbzKrxF)>jEHp))`^ zAOJ3G2U1p#OHzOwKnx%WumIoy^!W8w+YU;FfAYg0^tAf+rzgIQ!C>HdNJ|0509xZ! zAYBBg1l$1p3efHW-2h^i8Siv8sRZv;fJ1tLR(YXVib5^KvI=R%jazNaNQ3u+lYj+)1^{iHUPnseqxuPO^nGg2F_HBNl= zyEa;E>OT*g^Ufl4K}Tbv(dIHjv#4A!S9(IBt<;qk z@66O7HPhjP+2JMp7;Tdlb;{9wFv@>M8Ch#(L$no0%^r%vYA{I$VUv(1m4B)y6&jey zPx+Y5VY+$%jZKI~Z~`^IKS3e%IFOO69f^-2+MBs~pkn4-x>jNjmFb4V%wj*PlyP+J zS1Cu4tiifk@~EBtts`z20MQel-u64X(s+y-go(z*E+=L#8#t8KqLg=Fjd8@?$Lt?Q9uC~MZU)UwIJDw6JY4{@_ZO$5vfQ1}p0BTZjNiE26 z(zfrVCZ)HXXhAFP#AM}sV-jSObuZpA*$tk~^Up-~{E9fR5>ijSAgv0ElQOnNNlbG= zc6NL2j*|qlLrA;EtIrr_2IL1wEIB~Z#s**>ii^<421LLY85&WIjxIE8eG^4Z;h(k1 z>wJBXD79xUTXJ5~?uY~GB1jzCu&OdtXLATQKWS@i!IVr?E#QG4_Y!Y@!c#u((>35) zEIJI6w8O&WU1yqG^vGKn9w+_EpZqv_)W_KH!s4SPEmcu8jp=ODH$X-1ozRh+tU^lB zKQmsC$X#a5%?)KC=cL4vQK9FvInAXI7;*61>H<;ZEuS>j9>%tFZwCDop&Xv)yM7!SNUE{?Ud5adQyf z35$qtcL)o`T{-8V2R4hRFEG*ZG%Z-@+nyxUr3+7;Iw^Hydd5k;p$PQx6xdnqTO8H$ zF`x7gibU<<%ifWlK?!aSeiA$Q3w8S#VTl3TGVYC$&>a6bX(X5fmAD;6^oBz2Ls=2Z zhN0|r{?#X)t>37@P?f1r)-w%sOp8lp2q1K`bWjASG6wG52(li!%k0U*z$L_PHNZmG z&uLP|`ovX5De5(#-eA;WCPfrroE%=;@l9wHhQ(YKPYNiFN|Bc!(4kN`@Ew z*@TJMYsNljc*81B22wNpRsiy7m(OC%^`xTXROD-2t0fEeLGu7~6q36N9bjgKt2U(a zk3NmFR&7f6KiiKcT+ekITfbD;;O6}C&>B4Rp8^#@h3Kw8>l*5GQqjgCn(~YnWNj<$@(P z&#xZ^;uZ;O@u|laQ z5GxjJMqbSr?9EoF6$vp#3r8! zkce^wvAA;l$oVj94x~f7)H#4m?ERD0S3n{a6_6r^MEF;u43&;Vlo0 zlO8tZO7pO*J>QZmpKNzm&pEzs~5Kk6-2!3HNQ~Eg1BLuF3?$=0h(d6YOyNMk+7Asw39PX)ro{r4K zS_z9<68cpvhGyZUnC6Fv`e%9Qf(~^$k&eF{;KCvhfDXH%L;8HnjShqHOl|4%qYodI zeTlMJV{<)uo1l>}yobhTdjc^>$IS(PYsn~3GtKQl+QC-o%7MyGczT7WJ(4KwI0%kv znJ^CsD}Zk%zbs{rkU60>V(AFNsPlDjlYypMS>=~PjEp(F2XQ5pwCmS?p;&EL+#e!r zLtSraXVz+^3NKTI!Kg4a3`;?a`vfW!W9>ETDRcxMTL4NQvX1t^@yi-!>IteqhHs)EeY;|{$l zNL+uF1(`u|f*ZQIIto|FEbfV_Ais@-48ZSHlv6(D$oguM-?UuM`8@K2BRvn@k7atN zI4P+sOwG8yX)XFm9g3L9wB9Of3o<(=bc&0}?iv?S?P`n8L1AJ8InYMA9Q$Fq_R}|5 zf-MtFh4La%{`6U=1si6g6B{~t5*-L7iX0eHiY|x{iYl1tflQWD~8Gu=JK zoGh3fnqc7cLt7SvXe%~ODnz{wW`wpNifq`=ioygG7*Qb)g(5PO@SKXg9N#F+SfK}E z1VO>@{_-bbIt%JpP$v_0;8DD$0A~2z3~jLdnWCTPbEE3tzL zXJZ<#%*W`Vkz|ir^J3SYWV~aij95*wAMz}`dTf+*68V%j2=6rFvmj&+J{@BYi^l>y zG-d2WOq>|oMGpF#Q9lWFX=^8OTZ|No`IFjreU@2eRw4S(2_}TZV7-Q|aph%NFil|P zg1v{lOtarl?Y1qc!;H8%Y18ARc=Ezp4!GnFF`-WE4=0b#gc|s7^(?0p_1CEN(`1F7 z>P-Nl3p1@4AtOaQD=S(YGPm~wm1=~)bE~Q-nE0Yl_S`s>Q2?U`En&zz@^GAV(DB!tjd=69NaK8&Zq8*x(;A{zw z_lRq#!ZrMofC!F>)()hhC^VxIoz*q@Du9T<;ANieppv3hB66mQweBOWMSUD3 z%Ju)M*6&C4*)q>dAV4q%dti3%)5vq!K&+|CBj^ipG|Q^R3J_v;xN%do#njL-1qX@e zrM~d9I;6cDa-}|ae!n|w-8mGj!%VZwxYT9BxiTrKh}p==-)aoA9sp_zd`0l#RRo4+ z&D)p)rnp&y1A!rSy`jbwKG2-y!xVrm7xRe|(js*TIdpP}I=j>%;4Xt#ous_k$~*btGSupr48hHo|sypMx_J&+e*wl0%-GYtG83dPE$t{h^# z35>Tm+;h>`0YYw$AH&VE?8Vv_7&T;iJ4bvWiRTa%v+z7F$CR_fc5 zSn-w=ua52}PT#Z`-8dQ@qCC1e>skcl^DsgRKVUO=LBK<4glcq*^DTIxWgd;z=AwQ` z3p<#WH<6#UHsBsVjl3i2I~vQgg>jW@5{S~Iu5yuXv@u8bokK@>1Q_%1$`gn`-j@Do zk3na2xZjRb?BerVZ3Z3i?jDC_8;B<7V9uZ$1vZz)v{rT!P+hC;Z~m<1*S`2X7==cv zh~P;yf;FaPF2T62NigvyhaNIT*#+Ebs!0Np25<(xk+@_=NDYN+xjF>-7@5Ynyooxl zM#sLRCt6n&1IOuT>d2uiZzt}ga#%_uyCLYf=tE<3rV-9Sj2pPF_PxG(mn zGbh1#4%_+Xq>yhbJ>tn#?q56N=ny!>Bm~JBmC&En%`X90%V~%P@#_KrGd0TgN6w;^ z7CrZXGTH@cKw~o+%MB4MZ$?8B%a)H6KL;-mhB?rH5TYgKL%!WLu;+4Ap-|VirE&2C z&^sMRQVr{ya0$zu0hFf<{FW!fQ4xtQe=NS@%Onf*!!jKf-Cg48rJhdTE|s}sK6y?We)*gf1n$u<51e`*SIQcAlOE5bXmqeH z8%9N|UVXz-*FNCTn6_A(u!H7?6}Zztz}YUiF98!fjZ|cie-&$Oz%yM`XLx3q5Xga* zKml`LHMH)bF!f4_!@DK0PP!j%a=Il~x({oh8?mhQra9MBj1ZOD`PM(_Xh-XxpHNRj z;U+DFO3?`g$v>ju-+|T?xJq3UnL1FSe zs=uhR&w+Bm0PDA+Jw=?fp7jGh(j{_e{x!HWKF?3OIxj%V1l~PZH$`Ev%;<3EVIYgG z7%@>l?zECl0>!>gp$7-9KCOSPgUzuYaq%&vZULH1Vei$h3>E@Hp~as;a(EK_4_m!X%T#Y5>sE@ z+6C`Zy@UUhEB;J@O2ufAl$7e{)F4EvaD-JMFMt%+A%A7n5~ciT9KPHefzL4;YNbur z5I~TF#Ce<+DJfP5jJWY01au5*Ds)C$WV6+SGO+#jXIn9R^`ARidsCch!Ko|0Li%GK zOmowo!Cx`{ix4)cHfbKItxxlyIW5#1*_Q z?_Os+&_S>xjLu%kh{-M*yRwKxASLy;gD4^(o)Ck$#!fM~AF*DNIR(!I4a0WbWG`$@ zzA`{JPAM>;I$9$+$x-+8%)O?7_^ zacV(`wN#erWcXNSu*0>GOkaZYrtpR+CI3mAR*SmBmM|y2q7vH|BYPJ0Nu|jK@5Z~& z{)RyuTaB!>8ONSvf5DnM=~0OD5A4q>djc}R-vav)|I7G(1hujaVPA=s4ivg%tFp%i z_%R8Cp!e@Pkg~bFG;OuloxUi?CD4}k+@R4Hj_Lb_v7YVYHMga{|BThG0CGP@cyW3>iX^pE~4`7kK0;p4J~5EboeXvsgGjomO9!agI84o>gH=E&fKGSI zkuCt(0Cdt@iIfgnS0SZC+w(}1_Tf1J^GFUJY=FmMtWieB_Tib1jc*`L-H#tnsKoW( zwT`4G;~aa`;`YD3*$kE|L4MYSjejRf4 z%?#wqDIh1uU#coKJdn=ERTex+p5lOB_c7XG{Md1WoQsXvqhis1wo7dteRq2Z8xl(zPO>TV%vi33rIye* z>0UaWv{*a_gr#KfWTrIfXW#(nSgSH1v=NXm5UXuyGg}0AK^#q5_1FTDWAY$Dvic!x z-GWZa(7|AwXwb;20DI7296Gv+$e#i=51bN(Cgg@SY)Ny%bPZfFe)*MNcKw;N4beN2@gtc^-?4dQLfOT2mhs$MW~Gh&%UUyl-760lT3$tKc?pDX0)v>{vCGpTmavI9xiQ#hcOS@Sa(Q57rteP$~9OIJ_qXz8`v_ z5mvhN+}#@1N#Bj3l-08eOkG^K+Br`@AXthE)_W+J3>~o4tslT})-c7buW;=54FCr$ zUVKWo6tP9MkM2?&p9=$c6TN$4aTgyqGkEFMqKNllioWmeK%`rMn3nQSu6DMziR^aC zw|+FCPx0m<(xzAzlegE>KxZ&3JmYYbi#4m8f`Grv_2js#z8s1}0iG7~GTlfo^^23j zpg09sHjJa79A-pLW`)R((FEea@HGRLxby?Uq&Oq9dJ2&turRL`3nn`Hiip9zwT6z^ zyvA`FI;W)qbxQAPcz571o-}Ak7ib=|Q9k;GHa?7H<;UtK+bQgg_%8a*0p4;vZt!m@N3`3-%}16*Qw5G9IZAxDrQdR{o7(-qPqopbh`^a@>>5vgWA;=@Bt0A zz`2hh9cGnaT!sVIb^r zlDv-!E{iAlnqFFr-CcwozmGZ`v|^v;eG14=^8MGt41?8!(JH7B{k(N;N)H!#&cajt z()Gynm%s)3BBc<&-tZlSJ@{E5#0h&90LwNkEAWn{zUFqddFCme{I_8}21gI~7{TC0 z##(7DRxKpYFB70@c!t4DAqUF|s~xt>e7MB~rGnpuq<+cU~sNXKDtNnV;0zr>u_0IQ%m@pH^g zZ$K%1b0N)(^-C1{esL)dnL}XX-9oT@b$H_{be^zYOJqFs^TPUWJpQK?>-*64|21=| zbCw13lx*o3BSlMXtleCu!Mp@@q`6F)!>WHbhy6_D7}gkg1UhgC7_drqH-&?g>1YIa z6;VzL6a*GDFHuVKQiY8Sh<@5g5k_f0#of>qjWM_hK|7MEK)M(bCk?O-rAP-i;GhNc zsfN6tuWG;r3~tp&N^qlZSmPq0GMi!#6aZyS9i&RXMv)eEO0ga>oGkSk?u5`N=wdcG z=Et?FW9A=y*V*c9=Z-s|g$+6pldR|j>5Wg(E{#@xXODCtEr@g>N;-#=Z}Tu38ku9E z_ip9Nx_dK7CMs~5w-qx`3l!N~gH(@{&dn$&T!pkFe&N_#jRyf4RdT^(uiy7=h7RAi zwFtH<4|&g#c%~hM8OSeHpKI~F5zoGhTXbpbwL#~kQ~BT%*g(W+%`07VZnWt*evYmY z1EMToSPWPX)l?aDV;Jwie$4qQ|Ya`Zw`W zQp1BK5E@u@6L#%~4%fV=tSU%VN6-ZX2+n~ss!CLDZy{%wuvXbh&@SMu#juc=-&qJn zcbDYoE~J@P{2Xr8chi#{9PEWJ*o-hY`rX()40q1)6$;(E9}8JxG3xCOG1MZz7UkG8 zYpKHXDmDM^v8u&t_ zwCruha}?I(rN?lK8nIBP2JBDc=ecRyM7!_cv|pP1G*~I~ykDYTD;DpbEy<`9lts@(;govjPR{Ntt!d}r_V2{5`=;Pnh z0AvPd!CgYWU@fi`*xAMZ-@y8Zo!9;WL2TI{6PdU~VE+yL@gS`VQrDlA)TE~L?t?oU zn$LVbPDzxjI@|yv_0`xTsk>P5Zh_oDF18h|D%0Yk$ z9R{@P)JMQgl#$+-(FGbRbOMf;%eyu3p1`=$zXAQRkHRd%S6PX8d>W z4b{#AorUF^_;4J1B?9sQv)2f$3a|sv2nc{gWB%-%7A=$sK@F&2UJp?MCa!mW8Y9dV zyWoe1V*oo~+c`(zS^#E%qloX|e0 zWvO;|lF;40u&>Ysx$F80_aeF0SC~SD*-Bw~lF%Eu$^C`C_P3H`ESHjm(Y~w+{p76q z{p75}{e&qf2}=I0!s2AP!ZsxfP5atI`pa2BNoeG(=asBC`wLV2Fn|R|oCcE+C!jce zfKYASxC1)7>)(S5b?$`!)aJh|A0#9GFDtXFDzU3}IiE@q(mPqV9Dwy9vpwASgS__T z|A)_=*&h?&9BUTF3tcfkOgx7zU9jB-r~<44>_A@eOy}X@Lb{)!`G~--16%<8QGqq6 z@6AW-y3xX8B(hUSoJ&Sy${GxP)Vlgf8W$oxlg1-rs`E$4qxoB%U5Dwj=(xZZ0#X5c z0Kw{e(Q)UrvCtmji1R?M5T+H%?eC5kUPbbRMR+FEh8d{{U;gA^COrPOz_JVM{u6}H z#6*Mr(gYz_D0B8H5c~xlG@dyy3)hx8(+h=qG1UAX$Oc565?C5w&wKWtCJC<#<<0|> zh5JP!-F3tnIaP=jk(Y9>FhwUEbym+5I%|ZZ_U*HU6wx%u`Ppn?3Y90%6+#8;)vv*V zul~P*_5Ww*=o>R4jlSX=f#m>_07k%Nw=(8`HOa{#G;1VGeL=bFcbdLc3`Q_oTXCID4DjlzpZo%#PGJnIK_{Bn`- zy2cNAmybF(*@Qv6AYHG)%qKZ5thqxOMZ7y`3+6u%Ab zZ#E;!%yj<5g+T(*L+@tLLiAibY9F><@Cs%2_zm##Wp?}+{b)Z7jOpx$H=se8{n`eh zFA{@Yc$I#{J{zqw&{7$>{Y$$LFXou-x9q}Lq|UJ$g@J-Up*~w?U;dne4CS9bC)^J# zDVv1XyCAsE1{?z90Pst+68-Uo1!kxJW_XrPuj6CTH*oNUG^*TQwneCechPMX28uZa z&aAD%pM;RPT6P?S906R%&GQDc{meGu06?l;`MSMqHzfADeZy|yTI6YL1J_22j8Y$1qV1br5}t^?0nSlR8vzbL zBEB8XE7Y^yc&>TfnOqNzhyYrucL;e@Ms-kD{W8QINg3g4|0hx5EFg8Rv*i`xYq9q! z4XaEL+4=4w+t)*6!QDUz^2&RPtQ2`ykym1bNV?m<-zV6GQ_kxB!l+K-@oeX(Z=&Ns zbY#SMj%VBB8-x>GfLf*#C`yo>gvf_Ns=Pwytp?%GVmCY&egtbp`WfmZBE9&5!bxhE z^rUm|39v<2X#eu0unSYfl6QoTeu1cZ9B}HCbHfM15G}p$1C*58zdR%K)c}j#bw)@K z6EUZJEDY-ybh!-6L%_{B^tG|`sV{|ke}9_IZq9M`aSH{0*5)FSRZaW%V3{hiJK;aI z`7g@{$%y~U%B&cKP+iwFXG)XskDxAhgcyyd5pp1J$2sSypM+0EYwcW-EkJ|zFtPmi z@9oR}kDoiUKPJGr_h;eFj$&ScbH1N=$)6tb_2OhrP##2+2F1t*z^s}*SfOnL3!&1-*qr~H_j-`bSo`9FcLk2_MU-1%-5 z*`Ytf%>aDwzO;R#Vzn;ig`;l!vS#(YeSXv(b=uc&T#bV=%J6k`1eGfR^zH)}o+&Q? z9H6>4e8l3NkVhd6R?Dv9{UXZScj|j~AfNVF)?z=m0}cis6xatv_Q5gY-PWo#P|$w^ zXHiD*uj>4(@@BLlpmq$T(J!_n;k+UMldTpfV*-9|Ok`7>tudloZ*{$dfz&H-s@*f? z)wX-r;`#b+rQSuPv(;w{rT-16efauPQ>)zho*fF}G>6D`)PeJOKe8Qr;k%Ul_Kj|~ z%e(yi@5_JPro69GQ))L6c-puB{WDa7GJFJNgMfW^5Kx42%By)%sk>WEYe2|#5YUMC zg(^`2sOQ4-#&!hcsUyD$4xV}se-6M@?!q6JlP~<0I>+=C8-wG{U`s3;!3(@{Ii42* zN&v+G6M1UeU@^mK%Mh=`-G!_o09BQXOud8{du_4!K9;J}?-Soc(sR1_um;nJU4Nfw zvY*1=W>m_avX|a3j>GeT`^AruEGdzbf(IyZ;_sPu7D9m5JQA}f(ushga{Jf^#qFUB zKF6UezzjHh0n)kPy!D`H6$3%adBCYN&gHYjQJSoixJyj1`)hCl5QSs@1i%9$9)57w zicd2So+^1ZvHIQ2e>_=d3Clj0dGa;_WyLq~oHM9Qd?-{n<9uR~*dq8Z0yVag&RNfh z(*^&9cv&>k`GHmZKol2_bZ%H8zN-dapz;l$QZGInw#|VezL9K_~MO z9-^=qnZ@Uw@4hKUccgf-G~M~l`{GzZ#DXy8L-B3#<{bM^ABxxfX_1&yU~f4sZW1#J z?8`qA_ab!;JR^z%J*0muzR&@Dzx0_{=BG;9K!o4lo1s~Q7ZDZ06jK&i3-aF`x7nV0qe#4 zpw;)I3_vMCc)Mp`o-d72%M$-h9jcq!uH200JoUXuO-q#&i&QnEeOmDcdA{<6YFXvK zsY7*Z+Ldp?^KSM1keXgZ%1q9lS0VXn#6{vNiGes|L1;DH1uy}!0Y!jv8Jy4jA|B9+spIV_ zZQ{DniKGhhNeY)wQW9o5KWl>%_Jf+FJ@wl6nvNR3M)YynE^0M{gr}V8T1~vxpLknZ z=$sLtS%(EFUedpam<3WWzG;kRtkX46lkIO$8>-pupE&>F`7?VvFAdea;FtKo;|m_{H>Uz&CYx6}|1qQb zo^G7;|0=uRm^R8dfa6!5LI@CK$(&=scrvDBCRkYa!nqiNRT+pQj>I>OLhWs>tEDRi z3nH9^7mYzhL-O!~_y@_t3to_grHjGHYE@!zL9;-0eZdGXc)^SM=LIi_`hD(749jBT zlYIJn?(VtkUGBN--S1im$BgL3uGbAc*5wNsvfLx;+77hQyF>na=hM4=Jw~+8P!kR= zZ-2tS3zq#J@#N71&2)B$FBl3Au-fMvL`|u^iF$X$2+Lf9I3qjj*|T4`M4dz4^oZKi zU4DPm=;_fz!SLmtr}fK*5ebGb>5-_>7wqoMkJo#{v0z9K8}XPP=?;bD$>&5$Za*&? zRZppuZ7+yt<<>cokYIaH*FdDApcrr?E%Dcrzq?4aUXRtnAV!o&n-a04sY zKn~9DcvCc@0|5+T0`o{?1J3Vh2iH@)c+iP4%wPe_*uXBzNX%5hjb?ZeTTZ@wL7aKu zvGlt%h&zhwMQjcmI7bV!^?dizD2^GHzr%W*n% zK5?ZISfhqh9q#oq_l`JpzXsWh`3B8HG~gQk-X*vLR)Z5IX(p<-9}CJ)hD2SddI+TQ z!?0FD=HBLPW4*YC#obB{pH;Aqnhuq(Q@OeeKgv2&JW+Uy&VDQTswvKw75z-#=y%GW zT1Dk>mI0Fd+LnsUyrZH<)oj$&APZF-@}mK9T$X(xYD)uL1u2x|Zy$=b6G_*&xaLSc zb6p&HRCxX_kgu$Xhl^AnIjhNmHBnvCPQ9Hii2LQ$HStK{7Kf8x{;(#F7t%vk{POpF z^|oIgTert%{jw=v-SEpdRF&RWW5|!G7Sr(>2RPlxuhvBkm!6yJ97UhYl8hA^2oG^a NdQYunzNmey%0G$PgMa`4 diff --git a/bin/mdns/mdns-advertiser b/bin/mdns/mdns-advertiser index 6e55d1b9278c3315ecf273ca5022649104d3dfaf..490d5fc777771cfeaba1c08115daa6fa554a63c4 100755 GIT binary patch delta 52648 zcmZ^M4P2DP_WwNd?836HxV(tCBCM!Lgt`KnA}XSp2_lISnJJl>Kd%|rYi8zkQOOK} z&?U#b2CKKc<~6U0iJ32`Xx23|wc?uDzW4}&nVFTDx&QBZ_F1$0@9O7#=9ziUoH=vm z%$YN1W*=^x4zKzpyvoYW@2`rFPuQBNV+49e|0MipYJO{G5Yiwvx`45q)K#-udErV& zfpo$+=KyPynA3amfTIsjN_t$&9!A=uU#*hXtyYn3)y>@cii=s7DNR0K>!(k%w(9+k zrTm1kyN3Sa0mmdcc1&k2`vTIIGCfN~>ydzHfC;d0civ}>TV%EqP*SaDHk9=M#N77z zl95^g)8CR=-dm1C@|LKnD!F1W%X7NHw{hGM)^-sM>lX6C2OPI`Lu2m+#%^1!vYXW^ z%<53)nX^3uQD#Sv^x#RK!aE&sRO+Mn*8Pr6`U2zei#oP@ZYVo<(Ge7s9{)yp5YwSP zW6PA#t4{ZCd54-e-{+VXlp7R%Unt9HcI*nOW(UP&y^E2CqQ+3EdA zE8Dftam5hD5AAaVg`^wnP)apw_c_Ld44ttSS%yk;e7-Hr=JamU2)?XUehF#kzp*xx z)#**Nb%9JulsAD>)U0ETUG;1;`kRc-oP!uH09pXk@h%Tg1#kkkH`iSW375hNkMQYz zAz){-BfMQghrVdSbTZq+Hox97km(!`w0pbHb0{N`Pey7(wFaP@AyyNHgt77Ng#($z zY<4K4_OVvmeU5ljit*RItQDipCj9D{Vp?OoiW(OH))vQY)5e4zAQWi@|F&vH1_7wC z#f(PHXta1XYdiO3D2CB-w0!{|d7mRTbUY<<{mIeLSbn_RanYXyb)Z~bLWiSL3e_43 zRU6o%W66Mdfb|_DG^%4W0o>+vOID|wnQN5|kecq*y4bLfI;2xQc1g!f03TS%KspkT zHz<@HLOur1<)}Xz`IAVeVocvaYHg{D@0Jk4L1#=-E{|$};!OZ<4uI&<2!mT<0Rjt{&J+DC~xPJJOwiSNl7weZEhI`Fvjw>#zK)e-j301$t^tGR{#+%|vi ztUnj}p!zQTK^1e3Kex`GJMmyHQHdR+CUeG2q1=fvi=(!p?P`FFc);L%>E~fu_87w0 zmR&vG3sNkKh;lh{_bCT~FdNi@O( zkxv7>yaNA_+5oeIG1^FT0C6w2Jv0ZS{WQSQg7;tnY|ffH~@xIy${`s4O$jV`glR&D=#vORiOBY;w$+8OuL;(@{1vi8sDkw`-=S zUC>Un(RjJ8W}YWFz6H-SM=`blu;Nk1nvmatK4yh5wSOZoJ2o!b7}^9yW)(rK3MgAI zJI4R@BENpQE`4zlkEd5H+UxD8eFKpBjgIZT3Jc;#UtSW==Y3PRrDU6dx-jWwiwvPV zZvPPFC@&w)n+DZ=UfvpvSB^u~l|~{+gth|n)hTk2zir3>jijz--$L0w9sMKzY21<_=k-V%0LKAVFg6=$F(9(9o-IH+572|_SrgJlfKosS zpd7FQPz6{Es0J+hPRE{_F=NKZ$B!Ssj{Gfv31u;WWI*P3x^^rN4~2lq-_>nAI8-A3 zJcni(tG@?56c<%E3J#a?iLW?3hbM&-r^F?3wjOVRoZFboZ&o^L8sqtQm5xJ=WkJ-X zor4|YkIbiDestuh6iP)@k9xWHJ4ek21=P-uAB^XND;;BwjpDynI94A6g$l>6V+H(t zg(LLCb+ObM)r`y_g0Kuoe`~$&=!Xdse*y~m(Q0aF>Aw&0a6)eZbFDBABY_^7QCDyx zR_2W>>*jyDiSzj@9kFN9`H+>4NoS_;Z^|5d&g3Qq+8GEARW7kUeNjg;`$rX-i=RYzM zgHF&#cEH#3xow4G+t-s+wpXB)fQ+iHIV`TZ{KiXlm9A}i3ss`m{)&DvHXwiNnxo*N zhmT!em)>lVRI~sJp))K)B=NNG>*lodk@(IR>uN5|kogy-j-YQme@JyYhCMRF!LJmA zzF5lIzQc_3H}05mWt!R#ygi{kUL9OF;M*&5RMBl%WSo-mw1l2P z)dc_p`RAAFc}MA5hw-EZBX7cr03L_uP>h$=K(fX#RsxO^kuOC)66p$PN{3#I5upvB z(SWiy@Z1Bb6~Kmsvb7cUqk2jy)@g(^AERN@knRIwvY$Um(gz$&aW|mu9hUoU|?KD$1yy4R~a5KT+S+Psx*Nq`13@5)mRsL^Wcf%Xq0L z*;inn5axX8WUsqxB+7a=T5J9fYIYXZ36hC=&}V|QU;16uB2+E<6kD*ES>zFAl8G-B8}(&Er|NiLUi_b?Vmx#>OXFuI(>(AQ8P(O5y_LJ z$x$YqUyxf-jRZ*>4u}Q_l~E(LcMZRPm-VPr&~AbD?u?|b|NEoTS8}^@lvDxM>-6<2 zr%KP7M&r|#}!LY8jPFq#tC4TMCAhM3NINV7C$eo{uvmGLnD16H2$aqJG*7(ru~31rTZiMC+k9dVT#f zf0a51@%*WxY?(BGkDe+HQJOxr{^l~Nk341(&@Mr_KZQqg3UC4W9KipXW!8XndwZF+ z;=LhA&rTvg8!!;C6;KVh0hkO}`>2@vl2qAY{iC7G3S2W_LXf_`!wTtF9+c<^!pd16 zzEWCajG)O&1#kqnm{)K%IY=+we^vT3U*V|lvqqZ1gU(>J2l~3T(zKusvq7mEPy}cM zYP(wL!46c=2kS;D(vv8BqE@=!cmbJnfSE1zJ8Gp(QfN%Do<#z30I(f1Hb`@I5k&kA z6chr^09?WP`nETv_HrCCwg4s50fbTRf2(4x4c3cJZ%IlgLdrCtgMiCG2#|=_0u|^& zZ*#P_H}0ygGl21JQaAqIg!(?)r1^T)5a$6iV2H>0)oxFn6wA-75ohb96iP#P`qL~e zov)=^wDi1|hQI5_8>OWSwRD@7dbBiZm(+(F)`(HNq$F-yBj)ZxJ?Eoh(=KUwloNxu z86&fH7gwjkSwBx%^-@aE;_CD zh`v5`pY*I0nulB|pb&t$wQIjLmv7G#Ne86d!GF#3?}&HeUHb-jj7`14NUfsVp5yrd zMr!vTlpjR-IZ&@VAblovhy)ip02$|zM!gURrO(CL;QHLdXB;&-a!;&S*3=fnQl`0FcODAxmvI(Z1S@S$Gl0%8g0GcA|=X@s3u%wW|jVfra`ImesgW>~$*Z^DsAL0VTY41N!jUikemSNGelGOU3 ztJ3Eoe66Ma{Lj)TJ-;xwKEWpygy2CeHt;J^x*6E%&?qs7Vqzs88X>HQOG9{p97?YI z1Ooke(OmwAdHUzf%>Ike2a{N`aT``MfD-7Gry@1%0NRvi;Ck{sGT4yUrgz8 zEB~CZr*z}#64hT9&(Et53wvk}lM?tEmC&_&cxz}VIiv=3EfOZuyhgMR4L0 z^s>>9d?F9>2~QT5hxn=R=m8)=fRP@Jv~WQE?T2{0oItt}SUm~J1SruEXCtL`VZi_~ zY9fe1`l~1MEymETgJ5a^e(zI^DMA3Z1^IITuo?e2U&?n@h)s|4f^c%*qtjs!0Lg$A zmGz<1_;{Xh4wR|&z{_I1bgBqJGzrw5R#AHW^69)%=FcXGsF^(1@_2%}rS%?U{$TS^ z+$iBeB+cU6C_OuiuN!dTFQKYMizbT(YsNC2?iYIc_qmbS z-$rA9yRau~^~Z4iFycS5iRq%i5U8EK2N!;c=U7J^jp^KyXEV`uuG?~=x=qM zK%%HJ=V1;_ zgY~Qdqq!MyJzY#$#8Z+Cs2m9kJP1K*GlGNE*wmkWZQo;tN6bsk9Edu}fZ2oV_blR3 z6krpl{>pDBtw14d=Zm1_)IcNhRe%Km)r$e4T_6Gn_2yz8AGO!&UnE(P)7=vb>3J;e;8z9vx!Un)yE34wSO==)nhs{$1 z3Qq!PmsL0v`z1X80~W`HvwFsjDOx1d@EPHo@va2W0w75? z4HY#t{19IsEvD7-NfGpZh4#M5k0U;*f{#n-TL3&g>T?% zLN{aR48za^z`$YkyEgDFosq;@h=QD9^?VcN4=);4pRk$l(HVE6bSB{Pu=>`mdzaktD}-LAtlr) zL5!sc&pJS3S21@7PvHNE5bJhe{0S!-P;#aI#10IQkr3Cai1o4W@^qOm3Kx@h^V`1* zSX{sg*B_e#irz$D6La6=qwI^m;_MuPj%nB^PkxE3Cg|$dy)RY0U90%cRCve~b@;r( zQ~z~^$LL(?TGSiMXE6e^&|D)ll3}VGxZ-OY5`uFQJZJQ+RaSM#^z1>|$xz^;EQji_ zm9BSDcH5HeNj4An3`ALM`{8!A(!cN`xofS$O=TYA9$#AzJa=qg<_X4g44%8FH zJjdc$!ZSCPc?`(MU>*PUJzrZq>e90_wpRHW`KyQ$TZf|IDyO&3TILS1I=$Ss%tOyj zc;|n%>8qx zu`NJ$N$7>nyjJmnA?aea=S)haa$v8oO$wstDn-t8x?hu6D-pATSi8H4-9@V>=ny>v zByxw|D56oS{FCS%rRVjEy%k1*h&6SoRc^Tby}%^6(}M(4*$(Oj2K#ZjHcyk0BIu$J<)ZT$Wm8+yB7p+`eqbD;nNj;Ju;Y-7V19#a;A4iU_#J@1RKw2z{*phyw^IYp zgClC72P#a{Dwt8>*+1ZZHotOXpf;)*s+@({u;Za>p2rZUb-dZEl0(NeyjM0_4?dZ&!*KYM7yBWBP zZi*eEiPwlX0I{=%s49$xh)w;LN)QhR@n)@8Yk?c8;i~$g;a+I2eK*v#a(iX_O3*FY z(N3d18~9wq#{t+K#_g!QWqYSuMKZ5+z0!V(hig5ELl0859;Bi2_pZBagVxxBUbXLX z>&oq~v|qXddoooQOu%i?a527j8lvGfNNbu;*8AFq4$AffWsLCbHXgRShiQ#8fr+#K zqoVnNT7|@_%UtP_O{E^Pj<{r@8m>1`@3>a)?w(P{nvVMGnHpBQq;};V%pmWa_pw%J zb!T_7*DCw8$~a!U!!2y{mh~4QG6oOS1Vy3~_}#f5G&+=unshQ{X^YLzD= zSR2PmH(nzggYtj;52i>8uaFe(ffVe?S)QH7LvBN+)19DIz7SQbti4+jnWTsl+$2IB zDwm+LG$7k^ImMySZ4O*H!To|Y$~Z6~H#ogZk(a<}tyGDayUv$j1uXSs;oyPHX=L}+XneePETmA5 z;h}Y&hG%YHq%gbtFZ4Q z42A-AtX0C$oA#(oD_?X(iSkM)_C6r|WfW@*5P1khh#69-WZMW&bpC2ZSLAfZFCO6` z9GOjYW9lXao1JbmB=me&FjC@lN8;HOMoif&l=7fjB@zuWw$e2+0SaToj$?tZEdpaa zNM>b>O~fvPS*8Oo5ko_G8)yg+(s?imv-H-BFL~+an_! zN`qE;yf$VNw8}UPw&Xun>S4B5)iHxJYiS2!ZRjaAro&Ls@I*dQ1vl$eWoBNfCxjKa ze?X{DSKJYG9`;`4%;Ivss^}qhBYW6=i8G@Cbx--*nBb60l2rVX=SL9UK5&iN z*a;v~BXs`z6_DRZ!}TodtIeczWXI{M7Dbs42`h2Ju}XhYn6eKwMv3EO2^wX%Rj+VLn_@2 zJBz0Cy>OhuBOT=(>4L&+u#}9Qn{4}3eMjbp^*gJw52BXBv|8vlB(GMsIy{(#5s}?X zNzd<0xJb-Ub)s4QGc*!2lt#xi!l96tcKy(LC3?g%MF+K{d4iiXvSgFPi5Z$Nd&4nb zz<3t=OjaNpsSI`Q4aTJe`)@NfjJQj^!<~_(`Jb_l8Px_;$(-bSk+*jf(DMr1`>CNV>^%%;nTKUgVS(b8E;W_@gy-)I5 z{noHrJ-VQ()7!^=Z9XuBhF3w94iH|qmDeieAW|;+ALaK-TgBr?c^CU=lw{&g;CUE| zf1nvnHpXFK5qCz`WuR6faAJTn46K|1-s`}VZ0A%tVo)tSqs$|-$?n(jYy$o*bFFd- z-3`THo?;(02MGB>$2tQ> z0ww^a0iFgt2dDtN1vm;A2#aMqqGKoVd=}6GxCLn6gxfuUWWW$W4&YJ1EWq=C6#xO) z)HGSg-o-;B;55Jk_zrLjApb|li~uX37oay_D?ktU4iI}*#})!s0G`~fV=n+4c&2s;AUO**}vAYY?NDE zxcY?h8>lw`&oSK?n~iie@*4q%0Oye}<~UA<$hYj*v274&xqQ|46j_0pkZ>c!{tOuY zKLCzA*hMhc0S$%&CIVgrJdTDl!OUnt&vVetC_Njo7ahjCTWF;5^bfu)(0Ly3HUJEG zZ%ANlEgG5)IF2&oS31^&p{6@gKb{9pPn;@$ZP&38=*?9SIDYA-Plt+yAan!I51@Q3 z;9ql3qzQ+b>kv%U?nO6)VMYutlx6(yOQWq>>R;T-^cm3|(HOYSu z>kRyn%mHVW&RzZU$_9%~6WkW+pWoQ6SyuFmyJIF(@c!0a`TLMJr2O_i6?uEb5D>GS zR%c6J6!f?Y;a=ozcjbE_pK({dJMvfWY9J2zyBq9+{M`+9LVm$r^&>d#J+OWR92r88 zva$Qd)C{8-LyXhg(k)JTaXyxM*kQxr^~z2VVEH6o*x}N%O2f1`g}f}e$Qjl^@34@V zyfunExh!1Hb7JlmdUYkvFu8=Dr+6=C#dXv}&#waaMwlJqVC$O6^B&g4zap`@XAx^4 zLJxaMynInvcu28R`l3B9*rrlBZqYV4!-zm~cfUA!gHs|ejdY~j`m*q*LrWB@8&mai z_;8rT3BaRzg{aSMPH7K3ux;nM`+7hXJ6qQ#iGqv(Clq2rop+HBE$T(#; z+jvz2u^sCUAdn4#kugl{WC+4o^RagpBc}Aahfq5Ff$s!SoBB|^GcV_R;4bRm0+L= z(i#FcpN4~mc~##m&sT6rsN>6JJG0zC&xd?S4gy|&lH<`?9nC#-amoesL}y#yJOm;R zxboL~U^w6`jKup}ct3!|-=8GsU#H3YFd;u3@9S*q+>sr@5QZ-dvrLDSCu1$bdX$KI z$yCp-xinZRjNR}e2GKE|L{qXt*$g5%AP|EM4^`@dO4N%1P(vAV@v)C> zpXpg~3DX$5gBjo*p4zDRKFYRxB1eJnzjHj%L2=6d>>T%u9YBJjjD(m21Y$to-=UeF z1t8!{%J%ru;Yp(rnsV~JS)P9n9pTwOCd>Vhd{8l1mwAe!{7U){SkUMZwAq>9O%7(p zd1zxE-pxz!>&a8`cJwBJxbz36hCyN4f#dDBe61!RuZ$&A3$tuq;;EsH3&b95b4L8* zwol#kkkdP`9cyh*%p3(%a=DU*yaeUVpZcOFk zb~LgHBK;G5t7yk6WoQUAo@IJeH@*nFsLD)F{t$Bi9jNq(dlGh1GmG!{FelasTSt^l za$^BZGMJt29!w}^3pxZw;8wX4kw24Cu53{Cr9!a=gAquUIz*eT*}X9SXHseu+q3?i z))+=RtpYD;aSo7Ibn=U-Kc*Y)v<%oD3`aT-Z69&|8-b}#J<4OXYS50qlmF8{nv#LGyVP)Z=Ko?xm8 z%+QP=&Qrm8kHk22i^#!v3W(wAn#y=E7}pu%l+0*9ufDSv zd#{0Gbvuq;#h{0$&?~9xWp(_VcqWO!lP@(AJ;Cy6Nx3mLFQVDcUedp7KE>jk_ZD<;naGNb^fv6=E=Sl5#q zvOU2i=bA~OXeH~;3L{vFGeRnH%0K<;Gj444TS&q`Sd;6ajWa2ag)$uvc0AxyGZdWl#zqt)YVTm*EE~hYnQ5H{A2!aFgy=! z9Yb5xuHL%T?g;|N&FHBPJ#e1)wP6ffV^yc5LM76*cbVt;p=F+B8j+(Q0&Vwx_=~S~ zGl+GI@lVkhOi?c_;pk;ZgEJ!hmd|n+)9*NNjLKd$AItG{Bi-}Ey?*-B1!RTI>IR9xz zd8r3oP=iBqUTO0}%r}~U6w9N4z-U?+SEab%Bly)*_E)TOFgs(u1DiLY)4(R_)pj&2*&EZ%Ai8;vGLu&o8wtdAMw75V^55^kX#VHR%nl!*C@q8WS5|+L3 zD5H70?zY|6x)CcyvNa2?N0zz_MJA&W5WI+`=`t$O5Rg_Rquh-$TSj2e^SudJEdmj( z1TYdmXN1R~Iq@WkW+Nn1mFGbK(o$?&?lCb3+^_Lo)^OUBf_Se@bo-2Vk*dXz&kz8? zOmvg++mBXuqLp81VXUf9bb$A8P)A|QV04RI5c2^jLP;JX&2S}{Xvr*I?rA_8gx@6v zH=K5{D(By&DrX%$hAmf3=tWn*NQ~(E4e9}TdYRQ8DbpFT2;!00^LBMRp8)w-+d~;p z!2s9bHfC?y5acmyW!rh^dOrEPVOx!?OD4(#-r(6FW>k-|;N886Zhs!cT4#dJOwjp8 zU0MpgCaj6J+dj*JyXs-3=h4eb^)o{36yBNOHJP0@PTB(<()L!1Exz+CsmXmz%FL=&O^*`Uha~iJ8QFH-Da$Q<(oRWsR610}) zs%$N7{ubsb;L~mh&GEbwOCF=sou-*5G+-f@mfYD@p~F2DvBN#~Ch|8ik6d_}hM(7! zR)VOl1degGCk@v`);4r^Rfp2Bke#B77fyGs7d=CsdP;NDhMoqOINkj)rqr{(u!D`U ze#u};c!O{r>zATa(wkr6Sj3Gal?$<$i*)dM8*30DP$(y`Y`hbi>j^Qxqz3b!fwQy= zy`xHzadB#$@3cBt(GmW6$&-MglFVig10!#v%xS}N1BXj8jrYEViEqUEDK*dvG1;BW zpyX(`C$qDp5Nk>EbP$!;!>Ynb>_js&f1%vQOo;8>p!|Zl+O=zg>Yvb@r5W;dY!=Q( zE0v~0h@XQH&6K~vaM1oH#JZ~4@5_wGnpOjx8s%jgR_iGY>v->SFh|yigP~ph)H+!@o#;p!0v zM2!6o95hHRU}!2rvs9C4?(2_We>&H1677#Oc6ORZ{6?&w_}?_*WY+W$?f7?mZDH{A zwzI?Ti5l)n;6ewyRT}QSz{TY;IN15#Ty#oKD|HR+ob3r4vP@Mt-hiQihqt`La8Kmn zO6C0zFkHoE9PnejvQU}E%ZJLtP?tbMVw!lTXg$GU z{GFbV_eQ-SMWSg#y$Wl;%=40cxQ8^KS(jsbQKBm82XMYfL0?4AND?%yC7e3QkxN}P zo3{3@h2bi9aqAM-1`I3Nc%OLdEH~RroZiRb*3*)*@EkU*_u|R{?k@i`E zf1?C=mM=-lC{?$29ftx3 z3%>zQ&^*wjI~1y2&nWW@d2C#%X9F6ca|$}exdPeHzEg(=Xy3^*%2CxcR?2b|8>0V9*L5yMnbdBH*l)quBRgV(SMPo%su2oii zQwzt_rEXBj(c!kz=ET%;g$~u`0sk0!>dF9)3!KaA)w^bj5;m*6^w4eu?cF2`2T>rg@MEt@@uSSn;(o%D)%S+BSkY>TPo7 zNKY!FDTTP7V1k6`JvwK1xoFuZ1iZ{>gP5T#B*y$9l~4$Way?~PvVEZchr~0W z=8?d|qU|QUnVFSpu;V(%X$`h>lVP&94h9`agv8BUi0!kH*#0f^=)C311zxU3Vd%Xs z)s6;m5@EunmONCXb#!LY{T_0nF5p}$`#*jIzd8te%LL>nX!$ba zb!O<2nt#Z>9C=s6t7=0nhn6WW^;gi7-6bfe;kyv;P(3`!&hp$il{NM#b|^iHRx7g0 z=?;SB;0;dqvp`OC)hLM#>!JS3;1Es0wr;8C86twoi;K>!x?=2piZG!u6^Z#49J+Cw z?mrC=gUl9ngeE*0`!myE_~%wCtG@Dgiu3`SRlm8J1fpj18mtNe1Rio1ARmc*q?R9# zd^COx9&KB%(D5Kifwp!z#0)krUxS&d&NQy9mF_giXmGo+R@vE(war9+QHXyAwTF_^ zC5&Q!-tG!Pfy@xi$oI8Xp#qtg#3a9hWI>@8X=S7^2~a4~m5rnWAW#HCnj*Qzksjr# zu8c7n4W^;qt7u$+p%$EFr`?5n?4DZXeq0-nqHvJp3SXtLOaT0;UKonK(Y4AsRG_Kn z!4|-W$OuJ4hCGNppswN)O+!_iduMr;4;zUE8F55dCsL>dmvHEch$<)upJ2dp^6o0QK!2O&F1QOL#;GCx6s5;DJH27(sEQoyRrH&Bu zWV{~O{`==0e%tVi*1XwbNotepOu2#~3yaEKA_uVBj zNGwH*c#y+B8aO#%pbMm-gT!tk*hL-q6b#F3*`*J$J0xlKK;uMv4u+I&ew+jYk-_%g z%&Z*C+6=@Uh#5d^1=ayFt?hHjtm;}Al+;VaKLYU+IVdx=`28i^+CXHD-hWB&?;5Uo zI9xvlnyv&WD>^|EBcnu&(D)CtL#gH%Sle~&{s4z&-_V{%LK%tZQ6dUP{H8dvC18s6 znkmNqbkDF$I8yCaqul6Mql}Ki`hnhlff+&f7n898Af{4?Cqh3d2uC7%e zW}L&5G1-ix|38|dul~Ul9o)z74S6v|X*f;YS3&ESs+TQH*dU&PVh1F*6F7ES1QJte z>7=bH+e15nzoYlO+Y;B%d$K&%@NgxL$+QLWA|8ZrLV;T@a=v2?=jwDd;ld2o0upuE z1Tahz;r$%VBo!MZB4<{+Sl*lP^(MGN@{YJ};vx-6Nr&}A?>e36;+f8ORx{^l?zPHMP|gdnl`-n8x|Sp0C}#^JnT^Y<<`hO=?`LUm#>Wv7y1 zQvGVS+~bHFPGNWV{Xu@s$0l$~6Ly%)R)v^IzIPLLle)->Dp23Hd`<05m*Li0<;!MV3Akvym`{T*pTIJU<{zWGT+>oi>_RL7tQZ~ec zyCVM-re4hgGh{_+Q?J8#&&D~q^)QsRffNtkF#HTUg|ou8>|7m*=SRHv>6y`#=l3D7 z=0v&oX+6?sB4P@}>F{Lq;u{a#2!qb3PLLLet)ve?V(EUP+ z)nlQsbk<}43qmXUk@{nd;Y1KV-An!)b?2d+_O)^eK3{_k04X9O+s5hEYVz^Cqp&Mgly#)In3? zT>KKm@RwkdwFHBW7%!%TWPVoNoB=;I1oKuhFR42hU!&5eeK75Z!AuzL^*X~SW>2>q z$X%X|#!0IV(7YmhY{qS9lx0H^`e8hntwdP{h91X>Hyy1%h4g8_e856L-~asJYef3W zd$^M@|ElkAh+gi+$$5<1f2KbV>C1rE0ePR{Y=0fj>)%9X7vMO+=+UuwKqWxC0Kl5@ zJOIi*20(%K7Xfa-RX}DIcDfhvr3akhkDg^@c}OQA|5w10Gx!}k^7}u;FY@tSArIQ> zh}#)g@viP8d{M$}*Re?`r~u?Z$pbeXHskpRz$LgFBT!MjJb+3z5yVXRno!!?_)-K2 zCj-vu8A}Jw6L_EeF?xkd1S_7@vB!|oeG$6NaAEjWAKjdwO9#44$Qkd{+YWYIcbIzw zfC9Q$unsBRK=>Z1p(j&sF<5XD;bFi+0NqSDiZmIG<)Cr8kU+N=CPZKL1q=a&*D369 z!5XJr!tFd7#!eVQGlq{&IY;BTj#fk3f!dc3%kt3Su?aTYtSyU?SZ!#d9F6h`{o<6_ zW<>hfdNr6ro(L`Wy6!bf>W~qhcQwzYGn7$>+ee>`^0n=-eeNd3?A`<6%Cx0!9hP5t zAcd*muSs!AcRZ^ek*&h6IL%J)&Tfdh+E9qq(_ zho@1A9=VWDh83EQt9i6Nro&^HS?}_xIUe)DxZjKe7NGxj8f|Ec7AR{<93n|{?+VKv zxh}~#M$d=CSlz9gi&m$A<7jgI-f4U(`8TJr2z7|*Pr*!g`qTzQtlaRm(v1gc6l_4z zy5{KoYNcc;PRf9L@qPsCs$Olbnv2*OhG@7Qt`aSgw5P^Y^9EU+?zP}-9tE^<0R$1= zJ+wX>Q?#X$--V?FlektueF~48;;d z#3-aW0r+%{qA(&(F`z@l&?L}38{*%5w8x!dnaM42R-N7Nog9MegUZz4D>%!+&&=G{ z5z&i|s8m855IA)$^}K;q#o#URo7R3Mu3s!8JYNqT?lB=s@dkR^pf#4M^|tb^-X1}3 zX-PC7>_oGnB{i-iBxuFs6r5TiHB@Jw7f)l4CBs9{N61oZM5cM zZTID zx%M;!0CFbADdZB-4JnzusER%iEJ()tfNONJBPr#Jstd#*HHqPj3I>flpKoNpAh05T zl#C;Jk|-_T3!y8BZMW@28-b2cJCl0gTYo-ZjOs!cq5?5Y7ZO6rVKilW8kkww+juPe zJQs(PDHoGp2>0Cjg{PiAm{$bjM?MCmX2KAW#K_&cjxD^*!rb&@{6^3v7t4$e%f(BZ ze_e;41W{SlgPzGzu*{JBwr3txhnz-^ye1l6>eP({j21d&r{QF_JlsVuR}T4nXAR!< z%En31mAv~=UZWhWydQCNY)&u|q>E$-9$G`_vQjjN2ROY9=Y-Z16GTcg z@-_5)C-1Bz!#1hw2|5s1Pa^Jfx>Eu8AQ+YnIIw8&G7havz`UzhoI<;!Q?ubML*Yr4 zh7N+6zmEF>0cCXz8{uijjhGUMo){Q`v8D7+H2xrBxbADE(*-(Bpu9CPPI(8?ASp)E zjK#e&c*n*7PEIs#;Q;5Ko&C&9c;_Dm*7_@$m~J=3Deu9(F2dp^*{~N30wIhy&br*# z7#JCIdcWF+AJol^Q!=bo>h6}=Hh4b4ww!{=CTz`p7%qcrxf&Zxee8bEEl-I$TwBpF zG5<0cBuNm%dr?MGC`H*Bv~4FYh=CTopqaK7(d{aFhFzf@61OdBzJ*>$U_=MoLdt_p zf_i`K0K&C&+W0hPfo~yVM~fd&Pk+gN#JuJ|U7Cf4FoV2_A)sOVK-p3Cfjj3dmA$`j`+6&zav4Y-p?)IOK}a6+Y5lYUFN)dI9I5iWTZO(;bs&jWOB*6Jx|sfVo;QM)hCA z+B6<(kG*ja{YD|*y8&ZGN_9V!iWa3$5N2=K}_%63#JCe5f>}oKHG&rEWzqHb#V``yA>7j+C zp0B6mdV&_cq-tsLy?)JY!p=3Ixg{tg%{`4WQpSL;K&#zr(P$K1ATP@E;Nz=%NYhCP zNdg}LpWE!HU^}AFRe`-Yz29Lw;@-R&pG3lVVb`FLhfEdD;60tKlR~zDp!yRZJO`BN zFvLp7T#j#ZJ*)&YKcvw8dW>@+1-I2BFnVW5t5kI>K�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 wf-EqoE_YQR@`A87;;RkbBub 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 delta 47143 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

Yo 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 sizeof(g_mdns_counters.last_send_failure)) { + g_mdns_counters.last_send_failure[sizeof(g_mdns_counters.last_send_failure) - 1] = '\0'; + } +} + +static void log_mdns_counters(const char *reason) { + fprintf(stderr, + "mdns counters: reason=%s ipv4_rx=%lu ipv6_rx=%lu query_matches=%lu responses_sent=%lu send_failures=%lu last_send_failure=%s\n", + reason, + g_mdns_counters.ipv4_packets_received, + g_mdns_counters.ipv6_packets_received, + g_mdns_counters.query_packets_matched, + g_mdns_counters.responses_sent, + g_mdns_counters.send_failures, + g_mdns_counters.last_send_failure[0] != '\0' ? g_mdns_counters.last_send_failure : "(none)"); +} + +static void mdns_transport_requirements_from_links(const struct link_context_set *desired_links, + struct mdns_transport_requirements *requirements) { + int wants_ipv4 = link_contexts_need_ipv4_socket(desired_links); + int wants_ipv6 = link_contexts_need_ipv6_socket(desired_links); + + memset(requirements, 0, sizeof(*requirements)); + requirements->ipv4_required = wants_ipv4; + requirements->ipv6_required = !wants_ipv4 && wants_ipv6; +} + +static void mdns_transport_status_from_links(const struct link_context_set *desired_links, + const struct link_context_set *active_links, + const struct mdns_socket_pair *sockets, + struct mdns_transport_status *status) { + struct mdns_transport_requirements requirements; + + mdns_transport_requirements_from_links(desired_links, &requirements); + memset(status, 0, sizeof(*status)); + status->required_ipv4 = requirements.ipv4_required; + status->required_ipv6 = requirements.ipv6_required; + status->active_ipv4 = sockets->ipv4_fd >= 0 && link_contexts_need_ipv4_socket(active_links); + status->active_ipv6 = sockets->ipv6_fd >= 0 && link_contexts_need_ipv6_socket(active_links); + status->missing_required_ipv4 = status->required_ipv4 && !status->active_ipv4; + status->missing_required_ipv6 = status->required_ipv6 && !status->active_ipv6; + status->last_ipv4_errno = g_last_ipv4_socket_errno; + status->last_ipv6_errno = g_last_ipv6_socket_errno; +} + +static int mdns_transport_has_active_socket(const struct mdns_transport_status *status) { + return status->active_ipv4 || status->active_ipv6; +} + +static int mdns_transport_missing_required(const struct mdns_transport_status *status) { + return status->missing_required_ipv4 || status->missing_required_ipv6; +} + +static int mdns_transport_is_healthy(const struct mdns_transport_status *status) { + return mdns_transport_has_active_socket(status) && !mdns_transport_missing_required(status); +} + +static const char *mdns_transport_health_label(const struct mdns_transport_status *status) { + if (mdns_transport_is_healthy(status)) { + return "healthy"; + } + if (mdns_transport_has_active_socket(status)) { + return "degraded"; + } + return "down"; +} + +static void mdns_first_active_ipv4(char *out, size_t out_len, const struct link_context_set *active_links) { + size_t i; + + for (i = 0; i < active_links->count; i++) { + uint32_t ipv4_addr; + if (!link_context_has_mdns_ipv4_transport(&active_links->links[i])) { + continue; + } + ipv4_addr = link_preferred_ipv4_source(&active_links->links[i]); + if (ipv4_addr != 0) { + (void)ipv4_to_string(ipv4_addr, out, out_len); + return; + } + } + strncpy(out, "off", out_len - 1); + out[out_len - 1] = '\0'; +} + +static void mdns_first_active_ipv6(char *out, size_t out_len, const struct link_context_set *active_links) { + size_t i; + + for (i = 0; i < active_links->count; i++) { + if (!link_context_has_mdns_ipv6_transport(&active_links->links[i])) { + continue; + } + snprintf(out, out_len, "%s", active_links->links[i].name); + return; + } + strncpy(out, "off", out_len - 1); + out[out_len - 1] = '\0'; +} + +static void log_mdns_transport_status(const char *reason, + const struct link_context_set *active_links, + const struct mdns_transport_status *status) { + char ipv4_buf[INET_ADDRSTRLEN]; + char ipv6_buf[IFNAMSIZ + 1]; + + mdns_first_active_ipv4(ipv4_buf, sizeof(ipv4_buf), active_links); + mdns_first_active_ipv6(ipv6_buf, sizeof(ipv6_buf), active_links); + fprintf(stderr, + "mdns transport active: reason=%s status=%s ipv4=%s ipv6=%s required_ipv4=%d required_ipv6=%d missing_required_ipv4=%d missing_required_ipv6=%d last_ipv4_errno=%d last_ipv6_errno=%d\n", + reason, + mdns_transport_health_label(status), + status->active_ipv4 ? ipv4_buf : "off", + status->active_ipv6 ? ipv6_buf : "off", + status->required_ipv4, + status->required_ipv6, + status->missing_required_ipv4, + status->missing_required_ipv6, + status->last_ipv4_errno, + status->last_ipv6_errno); +} + +static int link_context_topology_equal(const struct link_context *a, const struct link_context *b) { + size_t i; + + if (strcmp(a->name, b->name) != 0 || + a->flags != b->flags || + a->ifindex != b->ifindex || + a->ipv4_count != b->ipv4_count || + a->ipv6_count != b->ipv6_count) { + return 0; + } + for (i = 0; i < a->ipv4_count; i++) { + if (a->ipv4[i].addr != b->ipv4[i].addr || + a->ipv4[i].netmask != b->ipv4[i].netmask) { + return 0; + } + } + for (i = 0; i < a->ipv6_count; i++) { + if (memcmp(&a->ipv6[i].addr, &b->ipv6[i].addr, sizeof(a->ipv6[i].addr)) != 0 || + a->ipv6[i].scope_id != b->ipv6[i].scope_id || + a->ipv6[i].prefix_len != b->ipv6[i].prefix_len || + a->ipv6[i].link_local != b->ipv6[i].link_local) { + return 0; + } + } + return 1; +} + +static int link_context_set_contains_topology(const struct link_context_set *set, + const struct link_context *ctx) { + size_t i; + + for (i = 0; i < set->count; i++) { + if (link_context_topology_equal(&set->links[i], ctx)) { + return 1; + } + } + return 0; +} + +static int link_context_topology_sets_equal(const struct link_context_set *a, + const struct link_context_set *b) { + size_t i; + + if (a->count != b->count) { + return 0; + } + for (i = 0; i < a->count; i++) { + if (!link_context_set_contains_topology(b, &a->links[i])) { + return 0; + } + } + return 1; +} + static void log_served_records(const struct config *cfg, const struct service_record_set *snapshot_records, int use_snapshot_records) { fprintf(stderr, "serving summary: source=%s\n", use_snapshot_records ? "snapshot" : "generated"); @@ -3741,9 +3955,24 @@ static int open_dualstack_mdns_sockets(int shared_bind, } if (need_ipv4) { out->ipv4_fd = open_bound_mdns_socket(shared_bind, log_bind_errors); - if (out->ipv4_fd < 0 || - configure_mdns_socket4_for_links(out->ipv4_fd, links, "runtime") != 0) { + if (out->ipv4_fd < 0) { ipv4_errno = errno; + g_last_ipv4_socket_errno = ipv4_errno; + fprintf(stderr, + "warning: mdns runtime socket: IPv4 bind 0.0.0.0:%d failed: %s\n", + MDNS_PORT, + strerror(ipv4_errno)); + disable_link_contexts_mdns_ipv4_transport(links); + compact_link_contexts_for_mdns_transport(links); + need_ipv4 = 0; + if (!need_ipv6) { + errno = ipv4_errno; + close_mdns_socket_pair(out); + return -1; + } + } else if (configure_mdns_socket4_for_links(out->ipv4_fd, links, "runtime") != 0) { + ipv4_errno = errno; + g_last_ipv4_socket_errno = ipv4_errno; if (out->ipv4_fd >= 0) { clear_deferred_response_for_sockfd(out->ipv4_fd); close(out->ipv4_fd); @@ -3758,8 +3987,10 @@ static int open_dualstack_mdns_sockets(int shared_bind, return -1; } fprintf(stderr, - "warning: mdns runtime socket: IPv4 setup failed (%s); continuing with remaining mDNS transports\n", + "warning: mdns runtime socket: IPv4 multicast setup failed after bind: %s; continuing with remaining mDNS transports\n", strerror(ipv4_errno)); + } else { + g_last_ipv4_socket_errno = 0; } } if (need_ipv6) { @@ -3767,6 +3998,7 @@ static int open_dualstack_mdns_sockets(int shared_bind, if (out->ipv6_fd < 0 || configure_mdns_socket6_for_links(out->ipv6_fd, links, "runtime") != 0) { ipv6_errno = errno; + g_last_ipv6_socket_errno = ipv6_errno; if (out->ipv6_fd >= 0) { clear_deferred_response_for_sockfd(out->ipv6_fd); close(out->ipv6_fd); @@ -3783,6 +4015,8 @@ static int open_dualstack_mdns_sockets(int shared_bind, close_mdns_socket_pair(out); errno = ipv6_errno; return -1; + } else { + g_last_ipv6_socket_errno = 0; } } compact_link_contexts_for_mdns_transport(links); @@ -3794,53 +4028,105 @@ static int open_dualstack_mdns_sockets(int shared_bind, return 0; } +static int open_dualstack_mdns_sockets_for_desired(int shared_bind, + const struct link_context_set *desired_links, + struct link_context_set *active_links, + int log_bind_errors, + struct mdns_socket_pair *out, + struct mdns_transport_status *status) { + int open_status; + struct link_context_set candidate_links; + + candidate_links = *desired_links; + open_status = open_dualstack_mdns_sockets(shared_bind, &candidate_links, log_bind_errors, out); + if (open_status != 0) { + memset(active_links, 0, sizeof(*active_links)); + mdns_transport_status_from_links(desired_links, active_links, out, status); + return -1; + } + *active_links = candidate_links; + mdns_transport_status_from_links(desired_links, active_links, out, status); + return mdns_transport_is_healthy(status) ? 0 : 1; +} + static int acquire_dualstack_mdns_sockets(int shared_bind, - struct link_context_set *links, - struct mdns_socket_pair *out) { + const struct link_context_set *desired_links, + struct link_context_set *active_links, + struct mdns_socket_pair *out, + struct mdns_transport_status *status) { static const unsigned int retry_delays_ms[TAKEOVER_RETRY_COUNT] = {0, 100, 200, 300, 400, 500}; size_t i; + int acquire_status; for (i = 0; i < TAKEOVER_RETRY_COUNT; i++) { kill_mdnsresponder(SIGTERM); sleep_millis(retry_delays_ms[i]); - if (open_dualstack_mdns_sockets(shared_bind, links, 0, out) == 0) { + acquire_status = open_dualstack_mdns_sockets_for_desired(shared_bind, desired_links, active_links, 0, out, status); + if (acquire_status >= 0) { if (mdns_takeover_confirmed(shared_bind)) { + if (mdns_transport_is_healthy(status)) { + fprintf(stderr, + shared_bind + ? "mDNS required transport shared bind established after SIGTERM + %ums\n" + : "mDNS required transport takeover established after SIGTERM + %ums\n", + retry_delays_ms[i]); + return 0; + } fprintf(stderr, - shared_bind - ? "mDNS dual-stack shared bind established after SIGTERM + %ums\n" - : "mDNS dual-stack takeover established after SIGTERM + %ums\n", + "mDNS transport degraded after SIGTERM + %ums; missing required ipv4=%d ipv6=%d, retrying takeover\n", + retry_delays_ms[i], + status->missing_required_ipv4, + status->missing_required_ipv6); + } else { + fprintf(stderr, "mDNS sockets acquired after SIGTERM + %ums but Apple mDNSResponder is still alive; retrying\n", retry_delays_ms[i]); - return 0; } - fprintf(stderr, "mDNS dual-stack sockets acquired after SIGTERM + %ums but Apple mDNSResponder is still alive; retrying\n", - retry_delays_ms[i]); close_mdns_socket_pair(out); + memset(active_links, 0, sizeof(*active_links)); } } for (i = 0; i < TAKEOVER_RETRY_COUNT; i++) { kill_mdnsresponder(SIGKILL); sleep_millis(retry_delays_ms[i]); - if (open_dualstack_mdns_sockets(shared_bind, links, 0, out) == 0) { + acquire_status = open_dualstack_mdns_sockets_for_desired(shared_bind, desired_links, active_links, 0, out, status); + if (acquire_status >= 0) { if (mdns_takeover_confirmed(shared_bind)) { + if (mdns_transport_is_healthy(status)) { + fprintf(stderr, + shared_bind + ? "mDNS required transport shared bind established after SIGKILL + %ums\n" + : "mDNS required transport takeover established after SIGKILL + %ums\n", + retry_delays_ms[i]); + return 0; + } fprintf(stderr, - shared_bind - ? "mDNS dual-stack shared bind established after SIGKILL + %ums\n" - : "mDNS dual-stack takeover established after SIGKILL + %ums\n", + "mDNS transport degraded after SIGKILL + %ums; missing required ipv4=%d ipv6=%d, retrying takeover\n", + retry_delays_ms[i], + status->missing_required_ipv4, + status->missing_required_ipv6); + } else { + fprintf(stderr, "mDNS sockets acquired after SIGKILL + %ums but Apple mDNSResponder is still alive; retrying\n", retry_delays_ms[i]); - return 0; } - fprintf(stderr, "mDNS dual-stack sockets acquired after SIGKILL + %ums but Apple mDNSResponder is still alive; retrying\n", - retry_delays_ms[i]); close_mdns_socket_pair(out); + memset(active_links, 0, sizeof(*active_links)); } } - if (!shared_bind && mdnsresponder_is_alive()) { - fprintf(stderr, "mDNS dual-stack takeover failed: Apple mDNSResponder is still alive after retry ladder\n"); - } else { - fprintf(stderr, "mDNS dual-stack takeover failed: could not acquire required UDP %d sockets\n", MDNS_PORT); + acquire_status = open_dualstack_mdns_sockets_for_desired(shared_bind, desired_links, active_links, 0, out, status); + if (acquire_status >= 0) { + if (mdns_transport_is_healthy(status)) { + fprintf(stderr, "mDNS required transport acquired after bounded takeover retry\n"); + return 0; + } + fprintf(stderr, + "mDNS transport degraded after bounded takeover retry; serving remaining transports with missing required ipv4=%d ipv6=%d\n", + status->missing_required_ipv4, + status->missing_required_ipv6); + return 1; } + fprintf(stderr, "mDNS required transport takeover failed: could not acquire any usable UDP %d transport\n", MDNS_PORT); errno = EADDRINUSE; return -1; } @@ -3917,6 +4203,7 @@ static void log_packet_send_failure_detail_any(const char *stage, const struct s int answers, int use_snapshot_records, int saved_errno) { char destbuf[96]; + remember_last_send_failure(stage, saved_errno); format_sockaddr_addr(dest, destbuf, sizeof(destbuf)); fprintf(stderr, "mdns packet send failure: stage=%s dest=%s packet_len=%lu answers=%d records=%s errno=%d (%s)\n", @@ -3947,6 +4234,7 @@ static int send_dns_packet_any(const char *stage, int sockfd, const uint8_t *buf return -1; } + g_mdns_counters.responses_sent++; if (strcmp(stage, "query_response") == 0) { if (!logged_success_reply) { char destbuf[96]; @@ -5383,6 +5671,9 @@ static int handle_query_any(int sockfd, questions.count = qdcount; suppress_planned_known_answers(packet, packet_len, cursor, ancount, &planned); + if (planned.count > 0) { + g_mdns_counters.query_packets_matched++; + } if (flags & DNS_FLAG_TC) { if (defer_planned_response(sockfd, @@ -5694,6 +5985,14 @@ static void send_link_announcement_pair(const struct mdns_socket_pair *sockets, int use_snapshot_records, const char *stage) { if (sockets->ipv4_fd >= 0 && link_context_has_mdns_ipv4_transport(link)) { + char sourcebuf[INET_ADDRSTRLEN]; + uint32_t source_ipv4 = link_preferred_ipv4_source(link); + fprintf(stderr, + "mdns announce: stage=%s family=ipv4 iface=%s source=%s records=%s\n", + stage, + link->name, + source_ipv4 != 0 ? ipv4_to_string(source_ipv4, sourcebuf, sizeof(sourcebuf)) : "unknown", + use_snapshot_records ? "snapshot" : "generated"); if (set_link_outbound_interface4(sockets->ipv4_fd, link) != 0 || send_announcement(sockets->ipv4_fd, dest4, cfg, link, ttl, snapshot_records, use_snapshot_records) != 0) { char detail[160]; @@ -5703,6 +6002,12 @@ static void send_link_announcement_pair(const struct mdns_socket_pair *sockets, } if (sockets->ipv6_fd >= 0 && link_context_has_mdns_ipv6_transport(link)) { struct sockaddr_in6 scoped_dest6; + fprintf(stderr, + "mdns announce: stage=%s family=ipv6 iface=%s source_ifindex=%u records=%s\n", + stage, + link->name, + link->ifindex, + use_snapshot_records ? "snapshot" : "generated"); scoped_mdns_dest6_for_link(&scoped_dest6, dest6, link); if (set_link_outbound_interface6(sockets->ipv6_fd, link) != 0 || send_announcement_any(sockets->ipv6_fd, @@ -5819,16 +6124,22 @@ static int prepare_runtime_mdns_sockets_for_links(int shared_bind, sockets->ipv4_fd = open_bound_mdns_socket(shared_bind, 1); if (sockets->ipv4_fd < 0) { ipv4_errno = errno; + g_last_ipv4_socket_errno = ipv4_errno; + fprintf(stderr, + "warning: mdns runtime socket: IPv4 bind 0.0.0.0:%d failed: %s\n", + MDNS_PORT, + strerror(ipv4_errno)); if (need_ipv6) { fprintf(stderr, - "warning: mdns runtime socket: IPv4 socket open failed (%s); continuing with remaining mDNS transports\n", - strerror(ipv4_errno)); + "warning: mdns runtime socket: IPv4 transport unavailable after bind failure; continuing with remaining mDNS transports\n"); disable_link_contexts_mdns_ipv4_transport(new_links); compact_link_contexts_for_mdns_transport(new_links); need_ipv4 = 0; } else { goto fail; } + } else { + g_last_ipv4_socket_errno = 0; } if (sockets->ipv4_fd >= 0) { opened_ipv4 = 1; @@ -5838,6 +6149,7 @@ static int prepare_runtime_mdns_sockets_for_links(int shared_bind, sockets->ipv6_fd = open_bound_mdns_socket6(shared_bind, 1); if (sockets->ipv6_fd < 0) { ipv6_errno = errno; + g_last_ipv6_socket_errno = ipv6_errno; if (need_ipv4 && sockets->ipv4_fd >= 0) { fprintf(stderr, "warning: mdns runtime socket: IPv6 socket open failed (%s); continuing with remaining mDNS transports\n", @@ -5848,6 +6160,8 @@ static int prepare_runtime_mdns_sockets_for_links(int shared_bind, } else { goto fail; } + } else { + g_last_ipv6_socket_errno = 0; } if (sockets->ipv6_fd >= 0) { opened_ipv6 = 1; @@ -5861,6 +6175,7 @@ static int prepare_runtime_mdns_sockets_for_links(int shared_bind, "runtime", &delta) != 0) { ipv4_errno = errno; + g_last_ipv4_socket_errno = ipv4_errno; if (ipv4_errno == EADDRNOTAVAIL && need_ipv6 && sockets->ipv6_fd >= 0) { fprintf(stderr, "warning: mdns runtime socket: IPv4 membership update found no usable links; continuing with remaining mDNS transports\n"); @@ -5875,6 +6190,9 @@ static int prepare_runtime_mdns_sockets_for_links(int shared_bind, goto fail; } } + if (need_ipv4) { + g_last_ipv4_socket_errno = 0; + } if (need_ipv6 && prepare_mdns_socket6_memberships(sockets->ipv6_fd, opened_ipv6 ? NULL : old_links, @@ -5882,6 +6200,7 @@ static int prepare_runtime_mdns_sockets_for_links(int shared_bind, "runtime", &delta) != 0) { ipv6_errno = errno; + g_last_ipv6_socket_errno = ipv6_errno; if (need_ipv4 && sockets->ipv4_fd >= 0) { fprintf(stderr, "warning: mdns runtime socket: IPv6 membership update failed (%s); continuing with remaining mDNS transports\n", @@ -5897,6 +6216,9 @@ static int prepare_runtime_mdns_sockets_for_links(int shared_bind, } goto fail; } + if (need_ipv6) { + g_last_ipv6_socket_errno = 0; + } compact_link_contexts_for_mdns_transport(new_links); if (new_links->count == 0) { errno = EADDRNOTAVAIL; @@ -5992,6 +6314,86 @@ static int apply_runtime_link_change(int shared_bind, return 0; } +static int recover_runtime_link_change_with_takeover(int shared_bind, + struct mdns_socket_pair *sockets, + struct link_context_set *active_links, + const struct link_context_set *desired_links, + const struct sockaddr_in *dest4, + const struct sockaddr_in6 *dest6, + const struct config *cfg, + const struct service_record_set *snapshot_records, + int use_snapshot_records, + struct mdns_transport_status *status) { + static const unsigned int retry_delays_ms[TAKEOVER_RETRY_COUNT] = {0, 100, 200, 300, 400, 500}; + size_t i; + + if (apply_runtime_link_change(shared_bind, + sockets, + active_links, + desired_links, + dest4, + dest6, + cfg, + snapshot_records, + use_snapshot_records) == 0) { + mdns_transport_status_from_links(desired_links, active_links, sockets, status); + if (mdns_transport_is_healthy(status)) { + return 0; + } + } else { + mdns_transport_status_from_links(desired_links, active_links, sockets, status); + } + + for (i = 0; i < TAKEOVER_RETRY_COUNT; i++) { + kill_mdnsresponder(SIGTERM); + sleep_millis(retry_delays_ms[i]); + if (apply_runtime_link_change(shared_bind, + sockets, + active_links, + desired_links, + dest4, + dest6, + cfg, + snapshot_records, + use_snapshot_records) == 0) { + mdns_transport_status_from_links(desired_links, active_links, sockets, status); + if (mdns_transport_is_healthy(status)) { + fprintf(stderr, "mDNS runtime required transport recovered after SIGTERM + %ums\n", + retry_delays_ms[i]); + return 0; + } + } else { + mdns_transport_status_from_links(desired_links, active_links, sockets, status); + } + } + + for (i = 0; i < TAKEOVER_RETRY_COUNT; i++) { + kill_mdnsresponder(SIGKILL); + sleep_millis(retry_delays_ms[i]); + if (apply_runtime_link_change(shared_bind, + sockets, + active_links, + desired_links, + dest4, + dest6, + cfg, + snapshot_records, + use_snapshot_records) == 0) { + mdns_transport_status_from_links(desired_links, active_links, sockets, status); + if (mdns_transport_is_healthy(status)) { + fprintf(stderr, "mDNS runtime required transport recovered after SIGKILL + %ums\n", + retry_delays_ms[i]); + return 0; + } + } else { + mdns_transport_status_from_links(desired_links, active_links, sockets, status); + } + } + + mdns_transport_status_from_links(desired_links, active_links, sockets, status); + return mdns_transport_has_active_socket(status) ? 1 : -1; +} + static int open_mdns_socket(int shared_bind, int log_bind_errors, uint32_t ipv4_addr, const char *socket_role) { int sockfd; @@ -6022,7 +6424,8 @@ int main(int argc, char **argv) { int print_smb_bind_interfaces = 0; int print_mdns_socket_families = 0; int auto_contexts_ready = 0; - struct link_context_set auto_links; + struct link_context_set desired_links; + struct link_context_set active_links; int capture_only = 0; int snapshot_capture_failed = 0; int snapshot_capture_skipped = 0; @@ -6032,7 +6435,8 @@ int main(int argc, char **argv) { memset(&cfg, 0, sizeof(cfg)); memset(&snapshot_records, 0, sizeof(snapshot_records)); - memset(&auto_links, 0, sizeof(auto_links)); + memset(&desired_links, 0, sizeof(desired_links)); + memset(&active_links, 0, sizeof(active_links)); strcpy(cfg.service_type, "_smb._tcp.local."); strcpy(cfg.adisk_service_type, "_adisk._tcp.local."); strcpy(cfg.adisk_disk_key, ADISK_DEFAULT_DISK_KEY); @@ -6347,23 +6751,28 @@ int main(int argc, char **argv) { if (auto_ip) { time_t last_iface_poll; + time_t last_degraded_retry; struct mdns_socket_pair sockets; + struct mdns_transport_status transport_status; if (!auto_contexts_ready) { - if (wait_for_auto_advertise_link_contexts(&auto_links, "mdns runtime") != 0) { + if (wait_for_auto_advertise_link_contexts(&desired_links, "mdns runtime") != 0) { return EXIT_AUTO_IP_UNAVAILABLE; } auto_contexts_ready = 1; } - log_link_contexts("mdns runtime auto-ip", &auto_links); + log_link_contexts("mdns runtime desired", &desired_links); sockets.ipv4_fd = -1; sockets.ipv6_fd = -1; - if (acquire_dualstack_mdns_sockets(shared_bind, &auto_links, &sockets) != 0) { + if (acquire_dualstack_mdns_sockets(shared_bind, &desired_links, &active_links, &sockets, &transport_status) < 0) { return EXIT_SOCKET_ACQUIRE_FAILED; } + log_link_contexts("mdns runtime active", &active_links); + log_mdns_transport_status("startup", &active_links, &transport_status); startup_burst_start_ms = monotonic_millis(); last_iface_poll = time(NULL); + last_degraded_retry = time(NULL); while (!g_stop) { fd_set rfds; @@ -6378,62 +6787,98 @@ int main(int argc, char **argv) { struct link_context_set next_links; memset(&next_links, 0, sizeof(next_links)); if (collect_usable_advertise_link_contexts_provider(&next_links, NULL) == 0 && - !link_context_sets_equal(&auto_links, &next_links)) { + !link_context_sets_equal(&desired_links, &next_links)) { struct link_context_set stabilized_links; - fprintf(stderr, "mdns auto-ip: interface table changed; confirming after %ds stabilization\n", + fprintf(stderr, + link_context_topology_sets_equal(&desired_links, &next_links) + ? "mdns desired transport state changed; confirming after %ds stabilization\n" + : "mdns desired link topology changed; confirming after %ds stabilization\n", AUTO_IP_STABILIZE_SECONDS); - log_link_contexts("mdns auto-ip old", &auto_links); - log_link_contexts("mdns auto-ip observed", &next_links); + log_link_contexts("mdns desired old", &desired_links); + log_link_contexts("mdns desired observed", &next_links); sleep(AUTO_IP_STABILIZE_SECONDS); memset(&stabilized_links, 0, sizeof(stabilized_links)); if (collect_usable_advertise_link_contexts_provider(&stabilized_links, NULL) == 0) { - if (link_context_sets_equal(&auto_links, &stabilized_links)) { - fprintf(stderr, "mdns auto-ip: observed interface change did not persist after stabilization\n"); + if (link_context_sets_equal(&desired_links, &stabilized_links)) { + fprintf(stderr, "mdns desired link change did not persist after stabilization\n"); } else { + desired_links = stabilized_links; if (stabilized_links.count > 0) { - log_link_contexts("mdns auto-ip stabilized", &stabilized_links); - if (apply_runtime_link_change(shared_bind, - &sockets, - &auto_links, - &stabilized_links, - &mdns_dest, - &mdns_dest6, - &cfg, - &snapshot_records, - use_snapshot_records) != 0) { - fprintf(stderr, "mdns auto-ip: could not apply stabilized address links; keeping previous links until next poll\n"); + log_link_contexts("mdns desired stabilized", &desired_links); + if (recover_runtime_link_change_with_takeover(shared_bind, + &sockets, + &active_links, + &desired_links, + &mdns_dest, + &mdns_dest6, + &cfg, + &snapshot_records, + use_snapshot_records, + &transport_status) < 0) { + fprintf(stderr, "mdns auto-ip: could not apply stabilized desired links; keeping existing active transport until next retry\n"); last_iface_poll = time(NULL); continue; } } else { fprintf(stderr, "mdns auto-ip: no usable address links after stabilization; sending goodbyes and waiting\n"); - send_link_goodbyes(&sockets, &auto_links, &mdns_dest, &mdns_dest6, &cfg, &snapshot_records, use_snapshot_records); + send_link_goodbyes(&sockets, &active_links, &mdns_dest, &mdns_dest6, &cfg, &snapshot_records, use_snapshot_records); close_mdns_socket_pair(&sockets); - memset(&auto_links, 0, sizeof(auto_links)); - if (wait_for_auto_advertise_link_contexts(&stabilized_links, "mdns runtime") != 0) { + memset(&active_links, 0, sizeof(active_links)); + memset(&desired_links, 0, sizeof(desired_links)); + if (wait_for_auto_advertise_link_contexts(&desired_links, "mdns runtime") != 0) { break; } - if (acquire_dualstack_mdns_sockets(shared_bind, &stabilized_links, &sockets) != 0) { + if (acquire_dualstack_mdns_sockets(shared_bind, &desired_links, &active_links, &sockets, &transport_status) < 0) { fprintf(stderr, "mdns auto-ip: usable address links returned but sockets could not be acquired\n"); last_iface_poll = time(NULL); continue; } - auto_links = stabilized_links; } - log_link_contexts("mdns auto-ip active", &auto_links); + log_link_contexts("mdns auto-ip active", &active_links); + log_mdns_transport_status("link_change", &active_links, &transport_status); + fprintf(stderr, "mdns auto-ip: re-announcing after link change\n"); startup_burst_start_ms = monotonic_millis(); startup_burst_index = 0; + last_degraded_retry = time(NULL); } } } last_iface_poll = time(NULL); } + mdns_transport_status_from_links(&desired_links, &active_links, &sockets, &transport_status); + if (mdns_transport_missing_required(&transport_status) && + time(NULL) - last_degraded_retry >= MDNS_DEGRADED_RETRY_SECONDS) { + fprintf(stderr, + "mdns desired transport state changed: retrying missing required transports ipv4=%d ipv6=%d\n", + transport_status.missing_required_ipv4, + transport_status.missing_required_ipv6); + if (recover_runtime_link_change_with_takeover(shared_bind, + &sockets, + &active_links, + &desired_links, + &mdns_dest, + &mdns_dest6, + &cfg, + &snapshot_records, + use_snapshot_records, + &transport_status) >= 0) { + log_link_contexts("mdns auto-ip active", &active_links); + log_mdns_transport_status("degraded_retry", &active_links, &transport_status); + fprintf(stderr, "mdns auto-ip: re-announcing after degraded transport retry\n"); + startup_burst_start_ms = monotonic_millis(); + startup_burst_index = 0; + } else { + log_mdns_transport_status("degraded_retry_failed", &active_links, &transport_status); + } + last_degraded_retry = time(NULL); + } + now_ms = monotonic_millis(); (void)flush_deferred_response_if_due(now_ms); while (startup_burst_index < STARTUP_BURST_COUNT && now_ms - startup_burst_start_ms >= (long long)g_startup_burst_offsets_ms[startup_burst_index]) { - announce_all_links(&sockets, &auto_links, &mdns_dest, &mdns_dest6, &cfg, &snapshot_records, use_snapshot_records, "startup_announce"); + announce_all_links(&sockets, &active_links, &mdns_dest, &mdns_dest6, &cfg, &snapshot_records, use_snapshot_records, "startup_announce"); startup_burst_index++; now_ms = monotonic_millis(); } @@ -6465,7 +6910,12 @@ int main(int argc, char **argv) { tv.tv_usec = (suseconds_t)((wait_ms % 1000) * 1000); { - int selected = maxfd >= 0 ? select(maxfd + 1, &rfds, NULL, NULL, &tv) : -1; + int selected; + if (maxfd < 0) { + sleep_millis(1000); + continue; + } + selected = select(maxfd + 1, &rfds, NULL, NULL, &tv); if (selected < 0) { if (errno == EINTR) { continue; @@ -6478,7 +6928,8 @@ int main(int argc, char **argv) { socklen_t src_len = sizeof(src); ssize_t nread = recvfrom(sockets.ipv4_fd, packet, sizeof(packet), 0, (struct sockaddr *)&src, &src_len); if (nread > 0) { - const struct link_context *link = select_response_link_ipv4(&auto_links, &src); + const struct link_context *link = select_response_link_ipv4(&active_links, &src); + g_mdns_counters.ipv4_packets_received++; if (link != NULL && (set_link_outbound_interface4_for_peer(sockets.ipv4_fd, link, src.sin_addr.s_addr) != 0 || handle_query(sockets.ipv4_fd, packet, (size_t)nread, &mdns_dest, &src, &cfg, link, &snapshot_records, use_snapshot_records) != 0)) { @@ -6488,6 +6939,7 @@ int main(int argc, char **argv) { (long)nread, inet_ntoa(src.sin_addr), (unsigned int)ntohs(src.sin_port)); log_send_failure("query_response", &mdns_dest, use_snapshot_records, detail); } + log_mdns_counters("ipv4_packet"); } } if (selected > 0 && sockets.ipv6_fd >= 0 && FD_ISSET(sockets.ipv6_fd, &rfds)) { @@ -6495,7 +6947,8 @@ int main(int argc, char **argv) { socklen_t src6_len = sizeof(src6); ssize_t nread = recvfrom(sockets.ipv6_fd, packet, sizeof(packet), 0, (struct sockaddr *)&src6, &src6_len); if (nread > 0) { - const struct link_context *link = select_response_link_ipv6(&auto_links, &src6); + const struct link_context *link = select_response_link_ipv6(&active_links, &src6); + g_mdns_counters.ipv6_packets_received++; if (link != NULL) { struct sockaddr_in6 scoped_dest6; int query_status; @@ -6525,13 +6978,14 @@ int main(int argc, char **argv) { srcbuf); } } + log_mdns_counters("ipv6_packet"); } } } } - send_link_goodbyes(&sockets, &auto_links, &mdns_dest, &mdns_dest6, &cfg, &snapshot_records, use_snapshot_records); + send_link_goodbyes(&sockets, &active_links, &mdns_dest, &mdns_dest6, &cfg, &snapshot_records, use_snapshot_records); close_mdns_socket_pair(&sockets); return 0; } diff --git a/src/timecapsulesmb/assets/artifact-manifest.json b/src/timecapsulesmb/assets/artifact-manifest.json index ebd07b42..4827774d 100644 --- a/src/timecapsulesmb/assets/artifact-manifest.json +++ b/src/timecapsulesmb/assets/artifact-manifest.json @@ -2,15 +2,15 @@ "artifacts": { "mdns-advertiser": { "path": "bin/mdns/mdns-advertiser", - "sha256": "1a072c376e56e43ec03c8a9158926dfd6c7a6486c4ebf23e30978e293f0629fb" + "sha256": "f8abe9740fa345a7d9a4b42c730fc5a1893dad8d4ce3a2c3522a2f5eefcbd90d" }, "mdns-advertiser-netbsd4le": { "path": "bin/mdns-netbsd4le/mdns-advertiser", - "sha256": "8dfb9461c57f8a45a531bd4b822e602e49b7d017adb2877cd04915d81a08d3f1" + "sha256": "6af3a4b7dcfbf3fa576f1b3a9d51bab9d3ac80632a1bb4e4d1d04bdef1671584" }, "mdns-advertiser-netbsd4be": { "path": "bin/mdns-netbsd4be/mdns-advertiser", - "sha256": "390a00fdc40b49e4c5698f942aa7e83a66bfd0821bd47e982a40945c1fed51cf" + "sha256": "0b415bd13d29fdc0e667ffaea2a74ca8e6022a2624e8d4be030cf486bb7b97cf" }, "nbns-advertiser": { "path": "bin/nbns/nbns-advertiser", diff --git a/src/timecapsulesmb/cli/doctor.py b/src/timecapsulesmb/cli/doctor.py index 2208fc02..2a981804 100644 --- a/src/timecapsulesmb/cli/doctor.py +++ b/src/timecapsulesmb/cli/doctor.py @@ -57,6 +57,10 @@ def _as_sequence(value: object) -> list[object]: return [] +def _bonjour_failure_uses_instance_match(results: list[CheckResult]) -> bool: + return any(result.status == "FAIL" and BONJOUR_INSTANCE_FAILURE_PREFIX in result.message for result in results) + + def _expected_bonjour_instance_from_results(results: list[CheckResult]) -> str | None: for result in results: if result.status != "FAIL" or BONJOUR_INSTANCE_FAILURE_PREFIX not in result.message: @@ -70,18 +74,7 @@ def _expected_bonjour_instance_from_results(results: list[CheckResult]) -> str | 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") +def _native_dns_sd_smb_names(native_dns_sd: object) -> list[str]: names: list[str] = [] for browse in _as_sequence(_mapping_value(native_dns_sd, "browses")): browse_type = str(_mapping_value(browse, "service_type") or "") @@ -101,25 +94,142 @@ def build_discovery_context(results: list[CheckResult], debug_fields: Mapping[st if not _bonjour_failure_uses_instance_match(results): return [] + lines: list[str] = [] + expected_summary, expected_instance = _bonjour_expected_summary(results, debug_fields) + if expected_summary: + lines.append(f"INFO expected Bonjour identity: {expected_summary}") zeroconf = _mapping_value(debug_fields, "bonjour_zeroconf") zeroconf_instance_count = _as_int(_mapping_value(zeroconf, "instance_count")) - if zeroconf_instance_count != 0: - return [] + if zeroconf_instance_count == 0: + lines.append( + "INFO Python zeroconf discovered 0 Bonjour instances during doctor; " + "mDNS advertiser/discovery path needs investigation" + ) + elif zeroconf_instance_count is not None: + lines.append( + f"INFO Python zeroconf discovered {zeroconf_instance_count} Bonjour instance(s), " + "but no matching _smb._tcp instance" + ) + if _authenticated_smb_listing_passed(debug_fields): + lines.append("INFO SMB works over unicast, but Bonjour discovered no matching _smb._tcp records") + zeroconf_summary = _zeroconf_debug_summary(zeroconf) + if zeroconf_summary: + lines.append(f"INFO Python zeroconf diagnostics: {zeroconf_summary}") + lines.extend(_mdns_transport_context_from_debug(debug_fields)) + lines.extend(_mdns_counter_context_from_debug(debug_fields)) + lines.extend(_native_dns_sd_context_from_debug(debug_fields, expected_instance=expected_instance)) + return lines + + +def _authenticated_smb_listing_passed(debug_fields: Mapping[str, object]) -> bool: + for attempt in _as_sequence(_mapping_value(debug_fields, "authenticated_smb_listing_attempts")): + outcome = _mapping_value(attempt, "outcome") + expected_share_found = _mapping_value(attempt, "expected_share_found") + if outcome == "pass" and expected_share_found is True: + return True + return False + + +def _debug_scalar_text(value: object) -> str | None: + if value is None: + return None + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (str, int, float)): + return str(value) + return None - 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}", +def _debug_summary_fields(value: object, keys: tuple[str, ...]) -> str: + parts: list[str] = [] + for key in keys: + text = _debug_scalar_text(_mapping_value(value, key)) + if text is not None: + parts.append(f"{key}={text}") + return " ".join(parts) + + +def _bonjour_expected_summary( + results: list[CheckResult], + debug_fields: Mapping[str, object], +) -> tuple[str, str | None]: + expected = _mapping_value(debug_fields, "bonjour_expected") + instance = _mapping_value(expected, "instance_name") + if not isinstance(instance, str) or not instance: + instance = _expected_bonjour_instance_from_results(results) + host_label = _mapping_value(expected, "host_label") + target_ip = _mapping_value(expected, "target_ip") + parts: list[str] = [] + if isinstance(instance, str) and instance: + parts.append(f"instance_name={instance!r}") + if isinstance(host_label, str) and host_label: + parts.append(f"host_label={host_label!r}") + if isinstance(target_ip, str) and target_ip: + parts.append(f"target_ip={target_ip!r}") + return " ".join(parts), instance if isinstance(instance, str) and instance else None + + +def _zeroconf_debug_summary(zeroconf: object) -> str: + return _debug_summary_fields( + zeroconf, ( - "INFO likely doctor false negative: native macOS mDNS saw the expected service " - "but Python zeroconf did not receive browse events" + "ip_version", + "zeroconf_interfaces", + "instance_count", + "resolved_count", + "service_event_count", + "ptr_record_count", + "resolve_attempt_count", + "resolve_success_count", + "resolve_error_count", ), - ] + ) + + +def _native_dns_sd_context_from_debug( + debug_fields: Mapping[str, object], + *, + expected_instance: str | None, +) -> list[str]: + lines: list[str] = [] + native_error = _mapping_value(debug_fields, "bonjour_native_dns_sd_error") + if isinstance(native_error, str) and native_error: + lines.append(f"INFO native dns-sd diagnostic error: {native_error}") + + native_dns_sd = _mapping_value(debug_fields, "bonjour_native_dns_sd") + summary = _debug_summary_fields(native_dns_sd, ("status", "timeout_sec", "elapsed_sec")) + if summary: + lines.append(f"INFO native dns-sd diagnostics: {summary}") + names = _native_dns_sd_smb_names(native_dns_sd) + if names: + names_text = ", ".join(repr(name) for name in names) + lines.append(f"INFO native dns-sd observed _smb._tcp instances: {names_text}") + else: + lines.append("INFO native dns-sd observed 0 _smb._tcp Add events") + if expected_instance is not None: + matched = "yes" if expected_instance in names else "no" + lines.append(f"INFO native dns-sd observed expected _smb._tcp instance: {matched}") + return lines + + +def _mdns_transport_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: + mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") + if not isinstance(mdns_log, str): + return [] + transport = _last_regex_group(r"mdns transport active: ([^\n]+)", mdns_log) + if not transport: + return [] + return [f"INFO mdns-advertiser transport state: {transport}"] + + +def _mdns_counter_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: + mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") + if not isinstance(mdns_log, str): + return [] + counters = _last_regex_group(r"mdns counters: ([^\n]+)", mdns_log) + if not counters: + return [] + return [f"INFO mdns-advertiser counters: {counters}"] def _last_regex_group(pattern: str, text: str) -> str | None: diff --git a/src/timecapsulesmb/discovery/bonjour.py b/src/timecapsulesmb/discovery/bonjour.py index 0a40b216..71ea85b5 100644 --- a/src/timecapsulesmb/discovery/bonjour.py +++ b/src/timecapsulesmb/discovery/bonjour.py @@ -551,17 +551,87 @@ def resolved_service_from_info(stype: str, info: Any) -> BonjourResolvedService: ) -def _open_zeroconf(interface_ip: str | None = None) -> Any: +def _ip_text(value: object) -> str | None: + if isinstance(value, tuple): + value = value[0] if value else "" + if not isinstance(value, str): + return None + try: + return str(ipaddress.ip_address(value)) + except Exception: + return None + + +def _adapter_ipv6_addresses_for_ipv4(source_ipv4: str) -> list[str]: + try: + import ifaddr + except Exception: + return [] + + try: + adapters = ifaddr.get_adapters() + except Exception: + return [] + + for adapter in adapters: + adapter_has_source_ipv4 = False + ipv6_addresses: list[str] = [] + for adapter_ip in getattr(adapter, "ips", []): + ip_text = _ip_text(getattr(adapter_ip, "ip", None)) + if not ip_text: + continue + try: + ip_obj = ipaddress.ip_address(ip_text) + except Exception: + continue + if ip_obj.version == 4 and ip_text == source_ipv4: + adapter_has_source_ipv4 = True + elif ip_obj.version == 6 and not ip_obj.is_loopback and ip_text not in ipv6_addresses: + ipv6_addresses.append(ip_text) + if adapter_has_source_ipv4: + return ipv6_addresses + return [] + + +def _zeroconf_interfaces_for_target(target_ip: str | None) -> list[str] | None: + source_ipv4 = _source_ipv4_for_target(target_ip) + if not source_ipv4: + return None + return [source_ipv4, *_adapter_ipv6_addresses_for_ipv4(source_ipv4)] + + +def _zeroconf_ip_version(IPVersion: Any) -> tuple[Any, str]: + try: + return IPVersion.All, "All" + except AttributeError: + return IPVersion.V4Only, "V4Only" + + +def _zeroconf_ip_version_name() -> str: + try: + from zeroconf import IPVersion + except Exception: + return "All" + _ip_version, ip_version_name = _zeroconf_ip_version(IPVersion) + return ip_version_name + + +def _format_zeroconf_interfaces(interfaces: Sequence[str] | None) -> str: + if not interfaces: + return "All" + return ",".join(interfaces) + + +def _open_zeroconf(interfaces: Sequence[str] | None = None) -> Any: try: from zeroconf import IPVersion, Zeroconf except Exception as e: raise RuntimeError(missing_dependency_message("zeroconf", e)) from e - # Our Time Capsule targets advertise over IPv4, and zeroconf 0.147.x can - # miss _smb._tcp browse results on macOS when run in dual-stack mode. - if interface_ip: - return Zeroconf(interfaces=[interface_ip], ip_version=IPVersion.V4Only) - return Zeroconf(ip_version=IPVersion.V4Only) + ip_version, _ip_version_name = _zeroconf_ip_version(IPVersion) + if interfaces: + return Zeroconf(interfaces=list(interfaces), ip_version=ip_version) + return Zeroconf(ip_version=ip_version) def resolve_service_instance( @@ -575,8 +645,8 @@ def resolve_service_instance( except Exception as e: raise RuntimeError(missing_dependency_message("zeroconf", e)) from e - interface_ip = _source_ipv4_for_target(target_ip) - zc = _open_zeroconf(interface_ip) + interfaces = _zeroconf_interfaces_for_target(target_ip) + zc = _open_zeroconf(interfaces) try: info = zc.get_service_info(instance.service_type, instance.fullname, timeout_ms, question_type=DNSQuestionType.QM) finally: @@ -605,8 +675,8 @@ def discover_snapshot_detailed( ) -> tuple[BonjourDiscoverySnapshot, BonjourDiscoveryDiagnostics]: service_types = _matching_service_types(service) start = time.monotonic() - zeroconf_interface = _source_ipv4_for_target(target_ip) - zc = _open_zeroconf(zeroconf_interface) + zeroconf_interfaces = _zeroconf_interfaces_for_target(target_ip) + zc = _open_zeroconf(zeroconf_interfaces) ptr_observer: PtrRecordObserver | None = None ptr_records: list[BonjourPtrRecordObservation] = [] ptr_record_error: str | None = None @@ -647,7 +717,7 @@ def discover_snapshot_detailed( service_types=list(service_types), timeout_sec=timeout, elapsed_sec=round(time.monotonic() - start, 3), - ip_version="V4Only", + ip_version=_zeroconf_ip_version_name(), instance_count=len(sorted_instances), resolved_count=len(sorted_records), pending_count=collector.pending_count(), @@ -657,7 +727,7 @@ def discover_snapshot_detailed( resolve_success_count=collector.resolve_success_count, resolve_error_count=collector.resolve_error_count, zeroconf_version=_installed_zeroconf_version(), - zeroconf_interfaces=zeroconf_interface or "All", + zeroconf_interfaces=_format_zeroconf_interfaces(zeroconf_interfaces), zeroconf_apple_p2p=False, instances=sorted_instances, resolved=sorted_records, diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c108bfc..0705d27f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3999,7 +3999,7 @@ def fake_run_doctor_checks(*_args, **kwargs): self.assertIn(" None: + def test_doctor_failure_telemetry_reports_empty_zeroconf_without_dns_sd_diagnosis(self) -> None: output = io.StringIO() results = [doctor.CheckResult("FAIL", "no discovered _smb._tcp instance matched expected device instance 'Home'")] @@ -4012,6 +4012,8 @@ def fake_run_doctor_checks(*_args, **kwargs): kwargs["debug_fields"]["bonjour_zeroconf"] = {"instance_count": 0, "service_event_count": 0, "ptr_record_count": 0} kwargs["debug_fields"]["bonjour_native_dns_sd"] = { "status": "ok", + "timeout_sec": 6.0, + "elapsed_sec": 6.125, "browses": [ { "service_type": "_smb._tcp", @@ -4030,11 +4032,26 @@ def fake_run_doctor_checks(*_args, **kwargs): self.assertEqual(rc, 1) telemetry_error = self._telemetry_client.emit.call_args_list[-1].kwargs["error"] self.assertIn("Discovery context:", telemetry_error) - self.assertIn("INFO Python zeroconf discovered 0 Bonjour instances during doctor", telemetry_error) - self.assertIn("INFO native dns-sd discovered expected _smb._tcp instance 'Home'", telemetry_error) - self.assertIn("INFO likely doctor false negative", telemetry_error) + self.assertIn( + "INFO expected Bonjour identity: instance_name='Home' host_label='home' target_ip='10.0.0.2'", + telemetry_error, + ) + self.assertIn( + "INFO Python zeroconf discovered 0 Bonjour instances during doctor; " + "mDNS advertiser/discovery path needs investigation", + telemetry_error, + ) + self.assertIn( + "INFO Python zeroconf diagnostics: instance_count=0 service_event_count=0 ptr_record_count=0", + telemetry_error, + ) + self.assertIn("INFO native dns-sd diagnostics: status=ok timeout_sec=6.0 elapsed_sec=6.125", telemetry_error) + self.assertIn("INFO native dns-sd observed _smb._tcp instances: 'Home'", telemetry_error) + self.assertIn("INFO native dns-sd observed expected _smb._tcp instance: yes", telemetry_error) + self.assertNotIn("INFO native dns-sd discovered expected _smb._tcp instance", telemetry_error) + self.assertNotIn("likely doctor false negative", telemetry_error) - def test_doctor_error_does_not_report_false_negative_when_native_dns_sd_only_saw_other_instances(self) -> None: + def test_doctor_error_reports_native_dns_sd_as_telemetry_only(self) -> None: results = [ doctor.CheckResult( "FAIL", @@ -4047,6 +4064,7 @@ def test_doctor_error_does_not_report_false_negative_when_native_dns_sd_only_saw "bonjour_expected": {"instance_name": "Home"}, "bonjour_zeroconf": {"instance_count": 0}, "bonjour_native_dns_sd": { + "status": "ok", "browses": [ { "service_type": "_smb._tcp", @@ -4060,9 +4078,138 @@ def test_doctor_error_does_not_report_false_negative_when_native_dns_sd_only_saw ) self.assertIsNotNone(error) assert error is not None - self.assertNotIn("Discovery context:", error) + self.assertIn("Discovery context:", error) + self.assertIn("INFO expected Bonjour identity: instance_name='Home'", error) + self.assertIn("INFO Python zeroconf discovered 0 Bonjour instances during doctor", error) + self.assertIn("INFO native dns-sd diagnostics: status=ok", error) + self.assertIn("INFO native dns-sd observed _smb._tcp instances: 'Kitchen'", error) + self.assertIn("INFO native dns-sd observed expected _smb._tcp instance: no", error) + self.assertNotIn("INFO native dns-sd discovered expected _smb._tcp instance", error) + self.assertNotIn("likely doctor false negative", error) + + def test_doctor_error_preserves_expected_instance_from_failure_message_for_telemetry(self) -> None: + results = [ + doctor.CheckResult( + "FAIL", + "no discovered _smb._tcp instance matched expected device instance 'Home'", + ) + ] + error = doctor.build_doctor_error( + results, + { + "bonjour_zeroconf": {"instance_count": 0}, + "bonjour_native_dns_sd": { + "status": "ok", + "browses": [ + { + "service_type": "_smb._tcp", + "events": [ + {"service_type": "_smb._tcp", "action": "Add", "name": "Home"}, + ], + } + ], + }, + }, + ) + self.assertIsNotNone(error) + assert error is not None + self.assertIn("INFO expected Bonjour identity: instance_name='Home'", error) + self.assertIn("INFO native dns-sd observed expected _smb._tcp instance: yes", error) + self.assertNotIn("likely doctor false negative", error) + + def test_doctor_error_reports_native_dns_sd_diagnostic_errors_as_telemetry(self) -> None: + results = [ + doctor.CheckResult( + "FAIL", + "no discovered _smb._tcp instance matched expected device instance 'Home'", + ) + ] + error = doctor.build_doctor_error( + results, + { + "bonjour_zeroconf": {"instance_count": 0}, + "bonjour_native_dns_sd_error": "RuntimeError: dns-sd broke", + }, + ) + self.assertIsNotNone(error) + assert error is not None + self.assertIn("INFO native dns-sd diagnostic error: RuntimeError: dns-sd broke", error) self.assertNotIn("likely doctor false negative", error) + def test_doctor_error_reports_unicast_smb_when_bonjour_is_empty(self) -> None: + results = [ + doctor.CheckResult( + "FAIL", + "no discovered _smb._tcp instance matched expected device instance 'Home'", + ) + ] + error = doctor.build_doctor_error( + results, + { + "bonjour_zeroconf": {"instance_count": 0}, + "authenticated_smb_listing_attempts": [ + { + "server": "home.local", + "outcome": "error", + "expected_share_found": False, + }, + { + "server": "Home.IoT", + "outcome": "pass", + "expected_share_found": True, + }, + ], + "remote_mdns_log_tail": ( + "mdns transport active: reason=startup status=degraded ipv4=off ipv6=bridge0 " + "required_ipv4=1 required_ipv6=0 missing_required_ipv4=1 missing_required_ipv6=0 " + "last_ipv4_errno=48 last_ipv6_errno=0\n" + "mdns counters: reason=ipv6_packet ipv4_rx=0 ipv6_rx=3 query_matches=2 " + "responses_sent=2 send_failures=1 last_send_failure=query_response errno=65 (No route to host)\n" + ), + }, + ) + self.assertIsNotNone(error) + assert error is not None + self.assertIn("Discovery context:", error) + self.assertIn("INFO SMB works over unicast, but Bonjour discovered no matching _smb._tcp records", error) + self.assertIn( + "INFO mdns-advertiser transport state: reason=startup status=degraded ipv4=off ipv6=bridge0 " + "required_ipv4=1 required_ipv6=0 missing_required_ipv4=1 missing_required_ipv6=0 " + "last_ipv4_errno=48 last_ipv6_errno=0", + error, + ) + self.assertIn( + "INFO mdns-advertiser counters: reason=ipv6_packet ipv4_rx=0 ipv6_rx=3 query_matches=2 " + "responses_sent=2 send_failures=1 last_send_failure=query_response errno=65 (No route to host)", + error, + ) + self.assertNotIn("INFO mdns-advertiser IPv4 transport active:", error) + + def test_doctor_error_ignores_legacy_mdns_transport_logs(self) -> None: + results = [ + doctor.CheckResult( + "FAIL", + "no discovered _smb._tcp instance matched expected device instance 'Home'", + ) + ] + error = doctor.build_doctor_error( + results, + { + "bonjour_zeroconf": {"instance_count": 0}, + "authenticated_smb_listing_attempts": [ + {"outcome": "pass", "expected_share_found": True}, + ], + "remote_mdns_log_tail": ( + "mdns auto-ip active: link[0] iface=bridge0 mdns_ipv4=1 mdns_ipv6=1\n" + ), + }, + ) + self.assertIsNotNone(error) + assert error is not None + self.assertIn("INFO SMB works over unicast, but Bonjour discovered no matching _smb._tcp records", error) + self.assertNotIn("INFO mdns-advertiser transport state:", error) + self.assertNotIn("INFO mdns-advertiser IPv4 transport active:", error) + def test_doctor_failure_telemetry_includes_derived_mdns_boot_context(self) -> None: output = io.StringIO() results = [ diff --git a/tests/test_deploy_modules.py b/tests/test_deploy_modules.py index 458c59bb..c3e8aa23 100644 --- a/tests/test_deploy_modules.py +++ b/tests/test_deploy_modules.py @@ -2763,6 +2763,183 @@ def test_mdns_advertiser_extracts_non_hardcoded_udp_service_type(self) -> None: self.assertEqual(run.returncode, 0, run.stderr) self.assertEqual(run.stdout.strip(), "_example-service._udp.local.") + def test_mdns_dualstack_takeover_keeps_desired_ipv4_after_bind_race(self) -> None: + mdns_source = (REPO_ROOT / "build" / "mdns-advertiser.c").as_posix() + source = r''' +#include +#include +#include +#include +#include +#include +#include +#include + +static int fake_socket(int domain, int type, int protocol); +static int fake_setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); +static int fake_bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); +static int fake_close(int fd); +static int fake_system(const char *cmd); +static int fake_usleep(useconds_t usec); +static FILE *fake_popen(const char *cmd, const char *mode); +static char *fake_fgets(char *s, int size, FILE *stream); +static int fake_pclose(FILE *fp); + +#define socket fake_socket +#define setsockopt fake_setsockopt +#define bind fake_bind +#define close fake_close +#define system fake_system +#define usleep fake_usleep +#define popen fake_popen +#define fgets fake_fgets +#define pclose fake_pclose +#define main mdns_advertiser_main +#include "@MDNS_SOURCE@" +#undef main +#undef pclose +#undef fgets +#undef popen +#undef usleep +#undef system +#undef close +#undef bind +#undef setsockopt +#undef socket + +static int next_fd = 100; +static int ipv4_bind_failures_remaining; +static int ipv4_bind_attempts; +static int ipv6_bind_attempts; + +static int fake_socket(int domain, int type, int protocol) { + (void)domain; + (void)type; + (void)protocol; + return next_fd++; +} + +static int fake_setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen) { + (void)sockfd; + (void)level; + (void)optname; + (void)optval; + (void)optlen; + return 0; +} + +static int fake_bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen) { + (void)sockfd; + if (addrlen >= sizeof(struct sockaddr_in) && addr != NULL && addr->sa_family == AF_INET) { + ipv4_bind_attempts++; + if (ipv4_bind_failures_remaining > 0) { + ipv4_bind_failures_remaining--; + errno = EADDRINUSE; + return -1; + } + } else if (addrlen >= sizeof(struct sockaddr_in6) && addr != NULL && addr->sa_family == AF_INET6) { + ipv6_bind_attempts++; + } + return 0; +} + +static int fake_close(int fd) { + (void)fd; + return 0; +} + +static int fake_system(const char *cmd) { + (void)cmd; + return 0; +} + +static int fake_usleep(useconds_t usec) { + (void)usec; + return 0; +} + +static FILE *fake_popen(const char *cmd, const char *mode) { + (void)cmd; + (void)mode; + return NULL; +} + +static char *fake_fgets(char *s, int size, FILE *stream) { + (void)s; + (void)size; + (void)stream; + return NULL; +} + +static int fake_pclose(FILE *fp) { + (void)fp; + return 0; +} + +static void make_desired_links(struct link_context_set *links) { + struct in6_addr ula; + memset(links, 0, sizeof(*links)); + append_link_ipv4(links, "bridge0", inet_addr("10.0.1.40"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); + if (inet_pton(AF_INET6, "fdbb:1111:2222:3333::40", &ula) != 1) { + return; + } + append_link_ipv6(links, "bridge0", &ula, 64, 7, IFF_UP | IFF_RUNNING); +} + +int main(void) { + struct link_context_set desired; + struct link_context_set active; + struct mdns_socket_pair sockets; + struct mdns_transport_status status; + int rc; + + make_desired_links(&desired); + memset(&active, 0, sizeof(active)); + sockets.ipv4_fd = -1; + sockets.ipv6_fd = -1; + ipv4_bind_failures_remaining = 2; + rc = acquire_dualstack_mdns_sockets(0, &desired, &active, &sockets, &status); + if (rc != 0) { + return 1; + } + if (!link_contexts_need_ipv4_socket(&desired) || !link_contexts_need_ipv6_socket(&desired)) { + return 2; + } + if (!status.active_ipv4 || !status.active_ipv6 || status.missing_required_ipv4) { + return 3; + } + if (ipv4_bind_attempts < 3 || ipv6_bind_attempts < 1) { + return 4; + } + close_mdns_socket_pair(&sockets); + + make_desired_links(&desired); + memset(&active, 0, sizeof(active)); + sockets.ipv4_fd = -1; + sockets.ipv6_fd = -1; + ipv4_bind_attempts = 0; + ipv6_bind_attempts = 0; + ipv4_bind_failures_remaining = 100; + rc = acquire_dualstack_mdns_sockets(0, &desired, &active, &sockets, &status); + if (rc != 1) { + return 5; + } + if (!link_contexts_need_ipv4_socket(&desired) || !status.missing_required_ipv4) { + return 6; + } + if (status.active_ipv4 || !status.active_ipv6) { + return 7; + } + if (ipv4_bind_attempts <= 1) { + return 8; + } + close_mdns_socket_pair(&sockets); + return 0; +} +'''.replace("@MDNS_SOURCE@", mdns_source) + run = self._compile_and_run_c_helper(source, "mdns_dualstack_takeover_desired_ipv4") + self.assertEqual(run.returncode, 0, run.stderr) + def test_mdns_runtime_socket_updates_roll_back_partial_memberships_and_fallback_to_ipv4(self) -> None: mdns_source = (REPO_ROOT / "build" / "mdns-advertiser.c").as_posix() source = r''' diff --git a/tests/test_discovery.py b/tests/test_discovery.py index e4eb2703..3d637b15 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -35,11 +35,16 @@ record_has_service, _open_zeroconf, _source_ipv4_for_target, + _zeroconf_interfaces_for_target, resolve_service_instance, ) from timecapsulesmb.cli.discover import run_cli # noqa: E402 +def make_fake_ip_version() -> types.SimpleNamespace: + return types.SimpleNamespace(V4Only=object(), All=object()) + + class DiscoveryTests(unittest.TestCase): def test_preferred_host_uses_hostname_then_ipv4(self) -> None: record = Discovered(name="TC", hostname="capsule.local", ipv4=["10.0.0.2"]) @@ -61,13 +66,12 @@ def test_discovered_record_root_host_returns_none_when_no_host_data_exists(self) record = Discovered(name="TC", hostname="", ipv4=[], ipv6=[]) self.assertIsNone(discovered_record_root_host(record)) - def test_discover_uses_ipv4_only_zeroconf(self) -> None: + def test_discover_uses_dual_stack_zeroconf(self) -> None: fake_zc = mock.Mock() fake_collector = mock.Mock() fake_collector.results.return_value = [] fake_collector.service_instances.return_value = [] - fake_ip_version = mock.Mock() - fake_ip_version.V4Only = object() + fake_ip_version = make_fake_ip_version() fake_zeroconf_module = mock.Mock(Zeroconf=mock.Mock(return_value=fake_zc), IPVersion=fake_ip_version) with mock.patch.dict(sys.modules, {"zeroconf": fake_zeroconf_module}): @@ -75,7 +79,7 @@ def test_discover_uses_ipv4_only_zeroconf(self) -> None: with mock.patch("timecapsulesmb.discovery.bonjour.time.sleep"): discover_resolved_records(timeout=0) - fake_zeroconf_module.Zeroconf.assert_called_once_with(ip_version=fake_ip_version.V4Only) + fake_zeroconf_module.Zeroconf.assert_called_once_with(ip_version=fake_ip_version.All) fake_collector.start.assert_called_once() fake_collector.resolve_pending.assert_called_once_with(timeout_ms=3000) fake_zc.close.assert_called_once() @@ -95,8 +99,7 @@ def test_discover_uses_selected_ipv4_interface_for_target(self) -> None: fake_ptr_observer = mock.Mock() fake_ptr_observer.observations.return_value = [] fake_ptr_observer.error = None - fake_ip_version = mock.Mock() - fake_ip_version.V4Only = object() + fake_ip_version = make_fake_ip_version() fake_zeroconf_module = mock.Mock(Zeroconf=mock.Mock(return_value=fake_zc), IPVersion=fake_ip_version) with mock.patch.dict(sys.modules, {"zeroconf": fake_zeroconf_module}): @@ -107,7 +110,7 @@ def test_discover_uses_selected_ipv4_interface_for_target(self) -> None: _snapshot, diagnostics = discover_snapshot_detailed(SMB_SERVICE, timeout=0, target_ip="10.0.1.77") source_mock.assert_called_once_with("10.0.1.77") - fake_zeroconf_module.Zeroconf.assert_called_once_with(interfaces=["10.0.1.42"], ip_version=fake_ip_version.V4Only) + fake_zeroconf_module.Zeroconf.assert_called_once_with(interfaces=["10.0.1.42"], ip_version=fake_ip_version.All) self.assertEqual(diagnostics.zeroconf_interfaces, "10.0.1.42") def test_discover_falls_back_to_all_interfaces_when_target_interface_is_unknown(self) -> None: @@ -125,8 +128,7 @@ def test_discover_falls_back_to_all_interfaces_when_target_interface_is_unknown( fake_ptr_observer = mock.Mock() fake_ptr_observer.observations.return_value = [] fake_ptr_observer.error = None - fake_ip_version = mock.Mock() - fake_ip_version.V4Only = object() + fake_ip_version = make_fake_ip_version() fake_zeroconf_module = mock.Mock(Zeroconf=mock.Mock(return_value=fake_zc), IPVersion=fake_ip_version) with mock.patch.dict(sys.modules, {"zeroconf": fake_zeroconf_module}): @@ -136,9 +138,29 @@ def test_discover_falls_back_to_all_interfaces_when_target_interface_is_unknown( with mock.patch("timecapsulesmb.discovery.bonjour.time.sleep"): _snapshot, diagnostics = discover_snapshot_detailed(SMB_SERVICE, timeout=0, target_ip="10.0.1.77") - fake_zeroconf_module.Zeroconf.assert_called_once_with(ip_version=fake_ip_version.V4Only) + fake_zeroconf_module.Zeroconf.assert_called_once_with(ip_version=fake_ip_version.All) self.assertEqual(diagnostics.zeroconf_interfaces, "All") + def test_target_interfaces_include_ipv6_addresses_from_selected_adapter(self) -> None: + class FakeAdapterIP: + def __init__(self, ip: object) -> None: + self.ip = ip + + class FakeAdapter: + ips = [ + FakeAdapterIP("192.168.50.9"), + FakeAdapterIP(("fe80::1234", 0, 4)), + FakeAdapterIP(("fd00::1234", 0, 4)), + ] + + fake_ifaddr = types.SimpleNamespace(get_adapters=mock.Mock(return_value=[FakeAdapter()])) + + with mock.patch.dict(sys.modules, {"ifaddr": fake_ifaddr}): + with mock.patch("timecapsulesmb.discovery.bonjour._source_ipv4_for_target", return_value="192.168.50.9"): + interfaces = _zeroconf_interfaces_for_target("192.168.50.77") + + self.assertEqual(interfaces, ["192.168.50.9", "fe80::1234", "fd00::1234"]) + def test_source_ipv4_for_target_uses_udp_route_selection(self) -> None: fake_sock = mock.Mock() fake_sock.getsockname.return_value = ("10.0.1.42", 5353) @@ -180,8 +202,7 @@ def test_discover_retries_pending_resolution_during_browse_window(self) -> None: fake_collector = mock.Mock() fake_collector.results.return_value = [] fake_collector.service_instances.return_value = [] - fake_ip_version = mock.Mock() - fake_ip_version.V4Only = object() + fake_ip_version = make_fake_ip_version() fake_zeroconf_module = mock.Mock(Zeroconf=mock.Mock(return_value=fake_zc), IPVersion=fake_ip_version) with mock.patch.dict(sys.modules, {"zeroconf": fake_zeroconf_module}): @@ -227,8 +248,7 @@ def test_discover_snapshot_detailed_returns_bounded_discovery_counters(self) -> ptr_observer_calls = mock.Mock() ptr_observer_calls.attach_mock(fake_ptr_observer.stop, "stop") ptr_observer_calls.attach_mock(fake_ptr_observer.observations, "observations") - fake_ip_version = mock.Mock() - fake_ip_version.V4Only = object() + fake_ip_version = make_fake_ip_version() fake_zeroconf_module = mock.Mock(Zeroconf=mock.Mock(return_value=fake_zc), IPVersion=fake_ip_version) with mock.patch.dict(sys.modules, {"zeroconf": fake_zeroconf_module}): @@ -249,7 +269,7 @@ def test_discover_snapshot_detailed_returns_bounded_discovery_counters(self) -> ]) self.assertEqual(diagnostics.service, "_smb") self.assertEqual(diagnostics.service_types, ["_smb._tcp.local."]) - self.assertEqual(diagnostics.ip_version, "V4Only") + self.assertEqual(diagnostics.ip_version, "All") self.assertEqual(diagnostics.elapsed_sec, 6.125) self.assertEqual(diagnostics.instance_count, 1) self.assertEqual(diagnostics.resolved_count, 1) @@ -268,8 +288,7 @@ def test_discover_includes_unresolved_browse_instances(self) -> None: instance = BonjourServiceInstance("_smb._tcp.local.", "Home", "Home._smb._tcp.local.") fake_collector.results.return_value = [] fake_collector.service_instances.return_value = [instance] - fake_ip_version = mock.Mock() - fake_ip_version.V4Only = object() + fake_ip_version = make_fake_ip_version() fake_zeroconf_module = mock.Mock(Zeroconf=mock.Mock(return_value=fake_zc), IPVersion=fake_ip_version) with mock.patch.dict(sys.modules, {"zeroconf": fake_zeroconf_module}): @@ -296,8 +315,7 @@ class FakeInfo: fake_zc = mock.Mock() fake_zc.get_service_info.return_value = FakeInfo() - fake_ip_version = mock.Mock() - fake_ip_version.V4Only = object() + fake_ip_version = make_fake_ip_version() fake_zeroconf_module = mock.Mock(Zeroconf=mock.Mock(return_value=fake_zc), IPVersion=fake_ip_version, DNSQuestionType=FakeQuestionType) instance = BonjourServiceInstance("_smb._tcp.local.", "Home", "Home._smb._tcp.local.") @@ -323,8 +341,7 @@ class FakeQuestionType: fake_zc = mock.Mock() fake_zc.get_service_info.return_value = None - fake_ip_version = mock.Mock() - fake_ip_version.V4Only = object() + fake_ip_version = make_fake_ip_version() fake_zeroconf_module = mock.Mock(Zeroconf=mock.Mock(return_value=fake_zc), IPVersion=fake_ip_version, DNSQuestionType=FakeQuestionType) instance = BonjourServiceInstance("_smb._tcp.local.", "Home", "Home._smb._tcp.local.") @@ -333,7 +350,7 @@ class FakeQuestionType: record = resolve_service_instance(instance, timeout_ms=750, target_ip="10.0.1.77") self.assertIsNone(record) - fake_zeroconf_module.Zeroconf.assert_called_once_with(interfaces=["10.0.1.42"], ip_version=fake_ip_version.V4Only) + fake_zeroconf_module.Zeroconf.assert_called_once_with(interfaces=["10.0.1.42"], ip_version=fake_ip_version.All) fake_zc.get_service_info.assert_called_once_with( "_smb._tcp.local.", "Home._smb._tcp.local.", @@ -680,8 +697,7 @@ def test_collector_results_preserve_raw_service_records(self) -> None: ] fake_collector.service_instances.return_value = [] fake_zc = mock.Mock() - fake_ip_version = mock.Mock() - fake_ip_version.V4Only = object() + fake_ip_version = make_fake_ip_version() fake_zeroconf_module = mock.Mock(Zeroconf=mock.Mock(return_value=fake_zc), IPVersion=fake_ip_version) with mock.patch.dict(sys.modules, {"zeroconf": fake_zeroconf_module}): From 10aa295c664ba25e86af2527f40eb8c4bac5d104 Mon Sep 17 00:00:00 2001 From: James Chang Date: Fri, 22 May 2026 17:53:19 -0700 Subject: [PATCH 036/129] Remove legacy mDNS iface-context path and NBNS static IPv4 modes --- bin/mdns-netbsd4be/mdns-advertiser | Bin 251608 -> 246420 bytes bin/mdns-netbsd4le/mdns-advertiser | Bin 253052 -> 247776 bytes bin/mdns/mdns-advertiser | Bin 311528 -> 305600 bytes bin/nbns-netbsd4be/nbns-advertiser | Bin 155172 -> 135724 bytes bin/nbns-netbsd4le/nbns-advertiser | Bin 155988 -> 136456 bytes bin/nbns/nbns-advertiser | Bin 211648 -> 194348 bytes build/mdns-advertiser.c | 872 ++-------------- build/nbns-advertiser.c | 74 +- .../assets/artifact-manifest.json | 12 +- tests/test_deploy_modules.py | 966 +++--------------- 10 files changed, 248 insertions(+), 1676 deletions(-) diff --git a/bin/mdns-netbsd4be/mdns-advertiser b/bin/mdns-netbsd4be/mdns-advertiser index 36c81a0d6d6fb92fd96161f87dc62c26eac98842..1c9293e3489ac8adf251d27dbb2a1c4920fc6d82 100755 GIT binary patch delta 37558 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 delta 42354 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 delta 42400 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%0Dhh+=G}8?aA~XoP@xy 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 delta 34174 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

)sT)JyDOFJZzY9Yj@5%!vZv*3B0l8T-)24|8 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 diff --git a/bin/nbns-netbsd4le/nbns-advertiser b/bin/nbns-netbsd4le/nbns-advertiser index f3e5222c36258d31b9305a7f2a247e108294d1a8..f91192a2773af700a8dc4c91faaf8b8dd95edc29 100755 GIT binary patch delta 15431 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 diff --git a/bin/nbns/nbns-advertiser b/bin/nbns/nbns-advertiser index ae1aa917fe004ce0b4c32c296b198c847ad3d923..776ade7bc785f3840e8e1cdbb8c6874a21af18df 100755 GIT binary patch delta 32478 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 delta 49659 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 SUjYW~xPpf*)z{zaWBwZ_4P~(a diff --git a/build/mdns-advertiser.c b/build/mdns-advertiser.c index 011e2ed3..1fdc1480 100644 --- a/build/mdns-advertiser.c +++ b/build/mdns-advertiser.c @@ -102,12 +102,6 @@ static volatile sig_atomic_t g_stop = 0; -#if defined(__GNUC__) -#define MDNS_UNUSED __attribute__((unused)) -#else -#define MDNS_UNUSED -#endif - #ifndef TC_VA_COPY #if defined(va_copy) #define TC_VA_COPY(dst, src) va_copy(dst, src) @@ -245,10 +239,6 @@ struct config { char host_label[MAX_LABEL + 1]; char host_fqdn[MAX_NAME]; char adisk_service_type[MAX_NAME]; - char adisk_share_name[MAX_NAME]; - char adisk_disk_key[MAX_LABEL + 1]; - char adisk_disk_advf[16]; - char adisk_uuid[ADISK_DISK_UUID_LEN + 1]; char adisk_shares_file[MAX_NAME]; char adisk_sys_wama[18]; struct adisk_disk_set adisk_disks; @@ -265,7 +255,6 @@ struct config { char airport_syvs[32]; char airport_srcv[32]; char airport_bjsd[16]; - uint32_t ipv4_addr; uint16_t port; uint16_t adisk_port; uint16_t airport_port; @@ -402,7 +391,6 @@ static const unsigned int g_startup_burst_offsets_ms[STARTUP_BURST_COUNT] = {0, static long long monotonic_millis(void); static int name_equals(const char *a, const char *b); static int build_instance_fqdn(char *out, size_t out_len, const char *instance_name, const char *service_type); -static int open_mdns_socket(int shared_bind, int log_bind_errors, uint32_t ipv4_addr, const char *socket_role); static int is_airport_enabled(const struct config *cfg); static int smb_enabled(const struct config *cfg); static int adisk_enabled(const struct config *cfg); @@ -451,42 +439,9 @@ static int flush_deferred_response_if_due(long long now_ms); static long long deferred_response_adjust_wait_ms(long long now_ms, long long wait_ms); static void clear_deferred_response_for_sockfd(int sockfd); -typedef int (*mdns_collect_iface_contexts_fn)(struct iface_context_set *out, void *userdata); typedef int (*mdns_collect_link_contexts_fn)(struct link_context_set *out, void *userdata); typedef void (*mdns_sleep_fn)(unsigned int seconds, void *userdata); -static void derive_usable_iface_contexts_from_links(struct iface_context_set *out, - const struct link_context_set *links) { - size_t i; - - memset(out, 0, sizeof(*out)); - out->truncated = links->truncated; - for (i = 0; i < links->count; i++) { - size_t j; - const struct link_context *link = &links->links[i]; - for (j = 0; j < link->ipv4_count; j++) { - (void)append_iface_context(out, - link->name, - link->ipv4[j].addr, - link->ipv4[j].netmask, - link->flags); - } - } - sort_iface_contexts(out); -} - -static int collect_usable_iface_contexts_provider(struct iface_context_set *out, void *userdata) { - struct link_context_set links; - - (void)userdata; - memset(&links, 0, sizeof(links)); - if (collect_usable_link_contexts(&links) != 0) { - return -1; - } - derive_usable_iface_contexts_from_links(out, &links); - return 0; -} - static int collect_usable_link_contexts_provider(struct link_context_set *out, void *userdata) { (void)userdata; return collect_usable_link_contexts(out); @@ -509,43 +464,6 @@ static void mdns_sleep_provider(unsigned int seconds, void *userdata) { sleep(seconds); } -static int wait_for_auto_iface_contexts_with_provider(struct iface_context_set *out, - const char *role, - mdns_collect_iface_contexts_fn collect_contexts, - mdns_sleep_fn sleep_fn, - void *userdata) { - struct iface_context_set first; - - if (collect_contexts == NULL || sleep_fn == NULL) { - return -1; - } - - memset(out, 0, sizeof(*out)); - while (!g_stop) { - memset(&first, 0, sizeof(first)); - if (collect_contexts(&first, userdata) == 0 && first.count > 0) { - fprintf(stderr, "%s auto-ip: first usable IPv4 observed; waiting %ds for network stabilization\n", - role, AUTO_IP_STABILIZE_SECONDS); - sleep_fn(AUTO_IP_STABILIZE_SECONDS, userdata); - if (collect_contexts(out, userdata) == 0 && out->count > 0) { - sort_iface_contexts(out); - return 0; - } - fprintf(stderr, "%s auto-ip: usable IPv4 disappeared during stabilization; retrying\n", role); - } - sleep_fn(AUTO_IP_STARTUP_POLL_SECONDS, userdata); - } - return -1; -} - -static int MDNS_UNUSED wait_for_auto_iface_contexts(struct iface_context_set *out, const char *role) { - return wait_for_auto_iface_contexts_with_provider(out, - role, - collect_usable_iface_contexts_provider, - mdns_sleep_provider, - NULL); -} - static int wait_for_auto_link_contexts_with_provider(struct link_context_set *out, const char *role, mdns_collect_link_contexts_fn collect_contexts, @@ -591,28 +509,71 @@ static int wait_for_auto_advertise_link_contexts(struct link_context_set *out, c NULL); } +static int print_link_ipv4_cidrs(FILE *stream, const struct link_context_set *set) { + int wrote = 0; + size_t i; + + for (i = 0; i < set->count; i++) { + size_t j; + const struct link_context *link = &set->links[i]; + + for (j = 0; j < link->ipv4_count; j++) { + char cidr[INET_ADDRSTRLEN + 4]; + + if (link_context_ipv4_cidr(cidr, sizeof(cidr), &link->ipv4[j]) != 0) { + return -1; + } + if (wrote && fputc(' ', stream) == EOF) { + return -1; + } + if (fputs(cidr, stream) == EOF) { + return -1; + } + wrote = 1; + } + } + if (!wrote) { + return -1; + } + if (fputc('\n', stream) == EOF) { + return -1; + } + return 0; +} + +static int link_contexts_have_ipv4_addr(const struct link_context_set *set) { + size_t i; + + for (i = 0; i < set->count; i++) { + if (set->links[i].ipv4_count > 0) { + return 1; + } + } + return 0; +} + static int print_auto_ip_cidrs_with_provider(FILE *stream, - mdns_collect_iface_contexts_fn collect_contexts, + mdns_collect_link_contexts_fn collect_contexts, void *userdata) { - struct iface_context_set contexts; + struct link_context_set links; if (collect_contexts == NULL) { return EXIT_AUTO_IP_PROBE_FAILED; } - memset(&contexts, 0, sizeof(contexts)); - if (collect_contexts(&contexts, userdata) != 0) { + memset(&links, 0, sizeof(links)); + if (collect_contexts(&links, userdata) != 0) { return EXIT_AUTO_IP_PROBE_FAILED; } - if (contexts.count == 0) { + if (links.count == 0 || !link_contexts_have_ipv4_addr(&links)) { return EXIT_AUTO_IP_UNAVAILABLE; } - if (contexts.truncated) { - fprintf(stderr, "auto-ip: usable IPv4 context list exceeded static capacity\n"); + if (links.truncated) { + fprintf(stderr, "auto-ip: usable address link list exceeded static capacity\n"); return EXIT_AUTO_IP_PROBE_FAILED; } - sort_iface_contexts(&contexts); - if (print_iface_context_cidrs(stream, &contexts) != 0) { + sort_link_contexts(&links); + if (print_link_ipv4_cidrs(stream, &links) != 0) { return EXIT_AUTO_IP_PROBE_FAILED; } return EXIT_OK; @@ -723,15 +684,13 @@ static void drop_mdns_multicast_group_best_effort(int sockfd, uint32_t ipv4_addr static void drop_mdns_multicast_group6_best_effort(int sockfd, unsigned int ifindex, const char *ifname, const char *socket_role); -static void log_startup_config(const struct config *cfg, int shared_bind, int auto_ip) { - char ipv4_buf[INET_ADDRSTRLEN]; - +static void log_startup_config(const struct config *cfg) { fprintf(stderr, "mdns startup: mode=%s instance=%s host=%s ipv4=%s service=%s adisk=%s device_model=%s airport=%s advertise=%s\n", - shared_bind ? "shared" : "exclusive", + "exclusive", cfg->instance_name[0] != '\0' ? cfg->instance_name : "(empty)", cfg->host_label[0] != '\0' ? cfg->host_label : "(empty)", - auto_ip ? "auto" : ipv4_to_string(cfg->ipv4_addr, ipv4_buf, sizeof(ipv4_buf)), + "auto", cfg->service_type[0] != '\0' ? cfg->service_type : "(empty)", adisk_enabled(cfg) ? "enabled" : "disabled", cfg->device_model[0] != '\0' ? cfg->device_model : "(empty)", @@ -1208,8 +1167,8 @@ static int service_type_set_add(struct service_type_set *set, const char *servic static void usage(const char *prog) { fprintf(stderr, - "Usage: %s --instance --host

|--auto-ip) [options]\n" - " %s --save-snapshot [--save-all-snapshot ] [airport identity options]\n" + "Usage: %s --instance --host
|--auto-ip) [options]\n" + "Usage: %s --name --auto-ip [options]\n" "Options:\n" " --auto-ip Answer with the matching live interface IPv4\n" - " --check-auto-ip Exit 0 if at least one usable live IPv4 exists\n" - " --version Print advertiser version code and exit\n" - " --ttl Record TTL (default: 120)\n", + " --version Print advertiser version code and exit\n", prog); } @@ -284,26 +282,6 @@ static int validate_netbios_name(const char *name) { return 0; } -static int parse_ttl_arg(const char *value, uint32_t *out) { - char *end = NULL; - long ttl; - - if (value == NULL || value[0] == '\0') { - fprintf(stderr, "ttl must be between 1 and 86400\n"); - return -1; - } - - errno = 0; - ttl = strtol(value, &end, 10); - if (errno != 0 || end == value || *end != '\0' || ttl <= 0 || ttl > 86400) { - fprintf(stderr, "ttl must be between 1 and 86400\n"); - return -1; - } - - *out = (uint32_t)ttl; - return 0; -} - static int decode_netbios_question_name(const uint8_t *encoded, size_t encoded_len, char out[16], uint8_t *suffix) { size_t i; @@ -690,8 +668,6 @@ int main(int argc, char **argv) { int yes = 1; int i; int auto_ip = 0; - int explicit_ipv4 = 0; - int check_auto_ip = 0; time_t last_iface_poll = 0; struct iface_context_set iface_contexts; @@ -712,23 +688,11 @@ int main(int argc, char **argv) { return 2; } memcpy(cfg.netbios_name, name_arg, name_len + 1); - } else if (strcmp(argv[i], "--ipv4") == 0 && i + 1 < argc) { - explicit_ipv4 = 1; - if (inet_aton(argv[++i], (struct in_addr *)&cfg.ipv4_addr) == 0) { - fprintf(stderr, "invalid IPv4 address\n"); - return 2; - } } else if (strcmp(argv[i], "--auto-ip") == 0) { auto_ip = 1; - } else if (strcmp(argv[i], "--check-auto-ip") == 0) { - check_auto_ip = 1; } else if (strcmp(argv[i], "--version") == 0) { printf("%d\n", ADVERTISER_VERSION_CODE); return 0; - } else if (strcmp(argv[i], "--ttl") == 0 && i + 1 < argc) { - if (parse_ttl_arg(argv[++i], &cfg.ttl) != 0) { - return 2; - } } else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { usage(argv[0]); return 0; @@ -738,34 +702,20 @@ int main(int argc, char **argv) { } } - if (check_auto_ip) { - struct iface_context_set check_contexts; - memset(&check_contexts, 0, sizeof(check_contexts)); - if (collect_usable_iface_contexts(&check_contexts) == 0 && check_contexts.count > 0) { - return 0; - } - return 1; - } - if (cfg.netbios_name[0] == '\0') { fprintf(stderr, "missing required option: --name\n"); return 2; } - if (auto_ip && explicit_ipv4) { - fprintf(stderr, "--auto-ip and --ipv4 are mutually exclusive\n"); + if (!auto_ip) { + fprintf(stderr, "missing required option: --auto-ip\n"); + usage(argv[0]); return 2; } - if (!auto_ip && cfg.ipv4_addr == 0) { - fprintf(stderr, "missing required option: --ipv4\n"); - return 2; - } - if (auto_ip) { - if (wait_for_auto_iface_contexts(&iface_contexts) != 0) { - return 1; - } - log_iface_contexts("nbns auto-ip active", &iface_contexts); - last_iface_poll = time(NULL); + if (wait_for_auto_iface_contexts(&iface_contexts) != 0) { + return 1; } + log_iface_contexts("nbns auto-ip active", &iface_contexts); + last_iface_poll = time(NULL); signal(SIGINT, on_signal); signal(SIGTERM, on_signal); @@ -816,7 +766,7 @@ int main(int argc, char **argv) { timeout.tv_sec = 1; timeout.tv_usec = 0; - if (auto_ip && refresh_auto_iface_contexts_if_needed(&iface_contexts, &last_iface_poll) != 0) { + if (refresh_auto_iface_contexts_if_needed(&iface_contexts, &last_iface_poll) != 0) { break; } @@ -843,7 +793,7 @@ int main(int argc, char **argv) { return 1; } - if (auto_ip) { + { uint32_t response_ip; struct config context_cfg = cfg; response_ip = choose_response_ipv4(&iface_contexts, peer.sin_addr.s_addr); @@ -853,8 +803,6 @@ int main(int argc, char **argv) { } context_cfg.ipv4_addr = response_ip; (void)maybe_respond_to_query(sock, &context_cfg, buf, (size_t)nread, &peer, peer_len); - } else { - (void)maybe_respond_to_query(sock, &cfg, buf, (size_t)nread, &peer, peer_len); } } diff --git a/src/timecapsulesmb/assets/artifact-manifest.json b/src/timecapsulesmb/assets/artifact-manifest.json index f64814f8..7c89b439 100644 --- a/src/timecapsulesmb/assets/artifact-manifest.json +++ b/src/timecapsulesmb/assets/artifact-manifest.json @@ -2,27 +2,27 @@ "artifacts": { "mdns-advertiser": { "path": "bin/mdns/mdns-advertiser", - "sha256": "397efad3b5dda49389eb78573a2e11e699eefa1000d385af0dba54c418516709" + "sha256": "fd0e6139bf3477d3cac6c5236cff1b75bd10392960e44ec806fbe2445a2ae47a" }, "mdns-advertiser-netbsd4le": { "path": "bin/mdns-netbsd4le/mdns-advertiser", - "sha256": "096b9d5646a306743b6fae620635a27c05f3c26c55915caefc8d1c0250269193" + "sha256": "6ee61c5a45f8dc64ae0c829265d5630a89d5d3add9afc4093d028c056c98619f" }, "mdns-advertiser-netbsd4be": { "path": "bin/mdns-netbsd4be/mdns-advertiser", - "sha256": "5d028254f0f9c9661dc1b0256f16f8d941863d9eea50efddbe06b36f87d4af1c" + "sha256": "e19b572179ee6c58445b762b85c68e5ab65dd4b2e2987c580e123d0e243ca982" }, "nbns-advertiser": { "path": "bin/nbns/nbns-advertiser", - "sha256": "fa1f29725106fd4e96250f136b475de46a138e2dfd8d7792d4cefae3df0e131a" + "sha256": "21a5f0fdd0cff9dae56d78befb195cb8a13020bd4591368444efc04c8a383181" }, "nbns-advertiser-netbsd4le": { "path": "bin/nbns-netbsd4le/nbns-advertiser", - "sha256": "33219b51e4de7457cf7bdaf9d8f94511f8ee1fce1cd0a2c9c761d15374b6ae21" + "sha256": "7b29f4632d5a071bec546ab2d08fd8a7c6acc34b9c90ec8c9902be8b26468073" }, "nbns-advertiser-netbsd4be": { "path": "bin/nbns-netbsd4be/nbns-advertiser", - "sha256": "c212b14faa77d230133513f464baa6a7cb7b32ef7f28e5972405442f43850ad2" + "sha256": "3f69b93dedd5dad56289ebfbf1e783dbc0a364e68714c5a18ef26e39d62845fc" }, "smbd": { "path": "bin/samba4/smbd", diff --git a/tests/test_deploy_modules.py b/tests/test_deploy_modules.py index c3e8aa23..321597d6 100644 --- a/tests/test_deploy_modules.py +++ b/tests/test_deploy_modules.py @@ -910,6 +910,8 @@ def test_mdns_advertiser_adisk_argument_validation_respects_diskless_mode(self) tmp = Path(tmpdir) shares_file = tmp / "adisk.tsv" shares_file.write_text(f"Data\tdk2\t{adisk_uuid}\t0x82\n") + bad_shares_file = tmp / "bad-adisk.tsv" + bad_shares_file.write_text("Data\tdk2\tbad\t0x82\n") def base_args(snapshot_name: str) -> list[str]: return [ @@ -930,12 +932,6 @@ def base_args(snapshot_name: str) -> list[str]: 0, "", ), - ( - "diskful_adisk_share_requires_adisk_sys_wama", - ["--adisk-share", "Data", "--adisk-uuid", adisk_uuid], - 7, - "", - ), ( "diskful_adisk_shares_file_requires_adisk_sys_wama", ["--adisk-shares-file", str(shares_file)], @@ -943,20 +939,14 @@ def base_args(snapshot_name: str) -> list[str]: "", ), ( - "diskful_adisk_share_rejects_invalid_adisk_sys_wama", - ["--adisk-share", "Data", "--adisk-uuid", adisk_uuid, "--adisk-sys-wama", "not-a-mac"], + "diskful_adisk_shares_file_rejects_invalid_adisk_sys_wama", + ["--adisk-shares-file", str(shares_file), "--adisk-sys-wama", "not-a-mac"], 7, "adisk sys waMA must be a MAC address", ), ( - "diskful_adisk_share_accepts_valid_adisk_sys_wama", - ["--adisk-share", "Data", "--adisk-uuid", adisk_uuid, "--adisk-sys-wama", "80:EA:96:E6:58:68"], - 0, - "", - ), - ( - "diskless_adisk_share_suppresses_missing_adisk_sys_wama", - ["--diskless", "--adisk-share", "Data", "--adisk-uuid", adisk_uuid], + "diskful_adisk_shares_file_accepts_valid_adisk_sys_wama", + ["--adisk-shares-file", str(shares_file), "--adisk-sys-wama", "80:EA:96:E6:58:68"], 0, "", ), @@ -967,14 +957,14 @@ def base_args(snapshot_name: str) -> list[str]: "", ), ( - "diskless_adisk_share_suppresses_invalid_adisk_sys_wama", - ["--diskless", "--adisk-share", "Data", "--adisk-uuid", adisk_uuid, "--adisk-sys-wama", "not-a-mac"], + "diskless_adisk_shares_file_suppresses_invalid_adisk_sys_wama", + ["--diskless", "--adisk-shares-file", str(shares_file), "--adisk-sys-wama", "not-a-mac"], 0, "", ), ( "diskless_still_validates_configured_adisk_disk_fields", - ["--diskless", "--adisk-share", "Data", "--adisk-uuid", "bad"], + ["--diskless", "--adisk-shares-file", str(bad_shares_file)], 8, "adisk uuid must be 36 characters", ), @@ -1170,7 +1160,7 @@ def test_mdns_advertiser_sets_cache_flush_for_unique_records_only(self) -> None: dest.sin_family = AF_INET; dest.sin_port = htons(5353); dest.sin_addr.s_addr = inet_addr("224.0.0.251"); - if (send_query_question(1, &dest, "home.local.", DNS_TYPE_A) != 0 || + if (send_query_question_any(1, (const struct sockaddr *)&dest, sizeof(dest), "home.local.", DNS_TYPE_A) != 0 || read_first_question_class(captured_packet, captured_len, &rrclass) != 0 || rrclass != DNS_CLASS_IN) {{ return 7; @@ -1201,33 +1191,6 @@ def test_mdns_advertiser_version_prints_version_code(self) -> None: self.assertEqual(run.stdout, "2104\n") self.assertEqual(run.stderr, "") - def test_mdns_advertiser_save_args_capture_and_exit_without_takeover(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - bin_path = self._compile_mdns_advertiser_binary(tmp) - all_snapshot = tmp / "allmdns.txt" - apple_snapshot = tmp / "applemdns.txt" - run = subprocess.run( - [ - str(bin_path), - "--save-all-snapshot", - str(all_snapshot), - "--save-snapshot", - str(apple_snapshot), - "--airport-wama", - "80:EA:96:E6:58:68", - ], - capture_output=True, - text=True, - check=False, - timeout=2, - ) - self.assertEqual(run.returncode, 12, run.stderr) - self.assertIn("mdns capture-only:", run.stderr) - self.assertIn("exiting without UDP 5353 takeover or advertisement", run.stderr) - self.assertNotIn("serving summary", run.stderr) - self.assertNotIn("mDNS takeover", run.stderr) - def test_mdns_timestamped_logging_truncates_long_lines_without_heap(self) -> None: mdns_source = (REPO_ROOT / "build" / "mdns-advertiser.c").as_posix() source = f''' @@ -1302,6 +1265,7 @@ def test_mdns_advertiser_can_skip_capture_when_snapshot_is_newer_than_boot(self) run = subprocess.run( [ str(bin_path), + "--auto-ip", "--save-all-snapshot", str(all_snapshot), "--save-snapshot", @@ -1397,64 +1361,6 @@ def test_mdns_advertiser_load_arg_requires_advertising_identity(self) -> None: self.assertIn("Usage:", run.stderr) self.assertNotIn("snapshot load:", run.stderr) - def test_mdns_advertiser_load_arg_reaches_takeover_after_loading_snapshot(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - bin_path = self._compile_mdns_advertiser_binary(tmp) - snapshot = tmp / "applemdns.txt" - self._write_matching_airport_snapshot(snapshot) - run = self._run_mdns_advertiser_until_ready_or_exit( - bin_path, - [ - "--shared-bind", - "--load-snapshot", - str(snapshot), - "--instance", - "TimeCapsule", - "--host", - "timecapsulesamba", - "--ipv4", - "127.0.0.1", - "--airport-wama", - "80:EA:96:E6:58:68", - ], - ) - self.assertIn("snapshot load: loaded 1 records, advertising 1 snapshot records", run.stderr) - self.assertIn("serving summary:", run.stderr) - self.assertNotIn("mdns capture-only:", run.stderr) - - def test_mdns_advertiser_save_and_load_args_preserve_combined_mode(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - bin_path = self._compile_mdns_advertiser_binary(tmp) - all_snapshot = tmp / "allmdns.txt" - apple_snapshot = tmp / "applemdns.txt" - self._write_matching_airport_snapshot(apple_snapshot) - run = self._run_mdns_advertiser_until_ready_or_exit( - bin_path, - [ - "--shared-bind", - "--save-all-snapshot", - str(all_snapshot), - "--save-snapshot", - str(apple_snapshot), - "--load-snapshot", - str(apple_snapshot), - "--instance", - "TimeCapsule", - "--host", - "timecapsulesamba", - "--ipv4", - "127.0.0.1", - "--airport-wama", - "80:EA:96:E6:58:68", - ], - ) - self.assertIn("warning: could not capture Apple mDNS snapshot", run.stderr) - self.assertIn("snapshot load: loaded 1 records, advertising 1 snapshot records", run.stderr) - self.assertIn("serving summary:", run.stderr) - self.assertNotIn("mdns capture-only:", run.stderr) - def test_mdns_advertiser_capture_only_validates_optional_dns_labels(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: tmp = Path(tmpdir) @@ -1475,45 +1381,37 @@ def test_mdns_advertiser_capture_only_validates_optional_dns_labels(self) -> Non self.assertIn("host label must not contain dots", run.stderr) self.assertNotIn("mdns capture-only:", run.stderr) - def test_mdns_advertiser_auto_ip_airport_snapshot_does_not_require_ipv4(self) -> None: + def test_mdns_advertiser_snapshot_capture_requires_auto_ip(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: tmp = Path(tmpdir) bin_path = self._compile_mdns_advertiser_binary(tmp) - snapshot = tmp / "airport.txt" run = subprocess.run( [ str(bin_path), - "--auto-ip", - "--save-airport-snapshot", - str(snapshot), - "--instance", - "TimeCapsule", + "--save-snapshot", + str(tmp / "applemdns.txt"), "--host", "timecapsule", - "--airport-wama", - "80:EA:96:E6:58:68", ], capture_output=True, text=True, check=False, ) - snapshot_exists = snapshot.exists() - self.assertEqual(run.returncode, 0, run.stderr) - self.assertIn("airport snapshot: wrote 1 record", run.stderr) - self.assertTrue(snapshot_exists) + self.assertEqual(run.returncode, 4) + self.assertIn("mDNS snapshot capture requires --auto-ip", run.stderr) + self.assertNotIn("mdns capture-only:", run.stderr) - def test_mdns_advertiser_rejects_auto_ip_with_explicit_ipv4(self) -> None: + def test_mdns_advertiser_auto_ip_airport_snapshot_does_not_require_ipv4(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: tmp = Path(tmpdir) bin_path = self._compile_mdns_advertiser_binary(tmp) + snapshot = tmp / "airport.txt" run = subprocess.run( [ str(bin_path), "--auto-ip", - "--ipv4", - "192.168.1.217", "--save-airport-snapshot", - str(tmp / "airport.txt"), + str(snapshot), "--instance", "TimeCapsule", "--host", @@ -1525,8 +1423,10 @@ def test_mdns_advertiser_rejects_auto_ip_with_explicit_ipv4(self) -> None: text=True, check=False, ) - self.assertEqual(run.returncode, 3) - self.assertIn("mutually exclusive", run.stderr) + snapshot_exists = snapshot.exists() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("airport snapshot: wrote 1 record", run.stderr) + self.assertTrue(snapshot_exists) def test_mdns_auto_ip_helpers_filter_and_detect_interface_changes(self) -> None: mdns_source = (REPO_ROOT / "build" / "mdns-advertiser.c").as_posix() @@ -1876,19 +1776,26 @@ def test_mdns_print_auto_ip_cidrs_returns_distinct_probe_failure_status(self) -> int mode; }}; -static int fake_collect_contexts(struct iface_context_set *out, void *userdata) {{ +static int fake_collect_contexts(struct link_context_set *out, void *userdata) {{ struct fake_auto_ip_plan *plan = (struct fake_auto_ip_plan *)userdata; memset(out, 0, sizeof(*out)); if (plan->mode == 1) {{ return -1; }} if (plan->mode == 2) {{ - append_iface_context(out, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); + append_link_ipv4(out, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); }} if (plan->mode == 3) {{ - append_iface_context(out, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); + append_link_ipv4(out, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); out->truncated = 1; }} + if (plan->mode == 4) {{ + struct in6_addr addr6; + if (inet_pton(AF_INET6, "fdbb:1111:2222:3333::40", &addr6) != 1) {{ + return -1; + }} + append_link_ipv6(out, "bridge0", &addr6, 64, 7, IFF_UP | IFF_RUNNING); + }} return 0; }} @@ -1915,6 +1822,10 @@ def test_mdns_print_auto_ip_cidrs_returns_distinct_probe_failure_status(self) -> if (print_auto_ip_cidrs_with_provider(stdout, fake_collect_contexts, &plan) != EXIT_AUTO_IP_PROBE_FAILED) {{ return 5; }} + plan.mode = 4; + if (print_auto_ip_cidrs_with_provider(stdout, fake_collect_contexts, &plan) != EXIT_AUTO_IP_UNAVAILABLE) {{ + return 6; + }} return 0; }} '''.format(mdns_source=mdns_source) @@ -2108,587 +2019,6 @@ def test_mdns_scoped_ipv6_multicast_destination_uses_link_ifindex(self) -> None: run = self._compile_and_run_c_helper(source, "mdns_scoped_ipv6_dest") self.assertEqual(run.returncode, 0, run.stderr) - def test_mdns_auto_ip_wait_stabilizes_after_first_usable_ipv4(self) -> None: - mdns_source = (REPO_ROOT / "build" / "mdns-advertiser.c").as_posix() - source = ''' -#include -#include -#define main mdns_advertiser_main -#include "{mdns_source}" -#undef main - -struct wait_plan {{ - int collect_calls; - int startup_poll_sleeps; - int stabilize_sleeps; -}}; - -static int fake_collect_contexts(struct iface_context_set *out, void *userdata) {{ - struct wait_plan *plan = (struct wait_plan *)userdata; - memset(out, 0, sizeof(*out)); - plan->collect_calls++; - if (plan->collect_calls >= 2) {{ - append_iface_context(out, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - }} - return 0; -}} - -static void fake_sleep(unsigned int seconds, void *userdata) {{ - struct wait_plan *plan = (struct wait_plan *)userdata; - if (seconds == AUTO_IP_STARTUP_POLL_SECONDS) {{ - plan->startup_poll_sleeps++; - }} else if (seconds == AUTO_IP_STABILIZE_SECONDS) {{ - plan->stabilize_sleeps++; - }} -}} - -int main(void) {{ - struct iface_context_set out; - struct wait_plan plan; - - memset(&out, 0, sizeof(out)); - memset(&plan, 0, sizeof(plan)); - g_stop = 0; - - if (wait_for_auto_iface_contexts_with_provider(&out, "test", fake_collect_contexts, fake_sleep, &plan) != 0) {{ - return 1; - }} - if (AUTO_IP_STARTUP_POLL_SECONDS != 2 || AUTO_IP_STABLE_POLL_SECONDS != 30) {{ - return 4; - }} - if (plan.collect_calls != 3 || plan.startup_poll_sleeps != 1 || plan.stabilize_sleeps != 1) {{ - return 2; - }} - if (out.count != 1 || strcmp(out.contexts[0].name, "bridge0") != 0 || - out.contexts[0].ipv4_addr != inet_addr("10.0.1.1")) {{ - return 3; - }} - return 0; -}} -'''.format(mdns_source=mdns_source) - run = self._compile_and_run_c_helper(source, "mdns_auto_ip_wait") - self.assertEqual(run.returncode, 0, run.stderr) - - def test_mdns_auto_capture_merges_all_context_snapshots(self) -> None: - mdns_source = (REPO_ROOT / "build" / "mdns-advertiser.c").as_posix() - source = ''' -#include -#include -#define main mdns_advertiser_main -#include "{mdns_source}" -#undef main - -struct fake_capture_plan {{ - struct service_record_set sets[2]; - int calls; -}}; - -static void add_airport_record(struct service_record_set *set, const char *host, const char *mac) {{ - struct service_record *record; - char txt[80]; - - record = find_or_add_record(set, AIRPORT_SERVICE_TYPE, "James's AirPort Time Capsule"); - if (record == NULL) {{ - return; - }} - snprintf(record->host_label, sizeof(record->host_label), "%s", host); - snprintf(record->host_fqdn, sizeof(record->host_fqdn), "%s.local.", host); - record->port = AIRPORT_DEFAULT_PORT; - snprintf(txt, sizeof(txt), "waMA=%s", mac); - snprintf(record->txt[0], sizeof(record->txt[0]), "%s", txt); - record->txt_len[0] = (uint8_t)strlen(record->txt[0]); - record->txt_count = 1; -}} - -static int fake_capture_context(struct service_record_set *out, const struct iface_context *ctx, void *userdata) {{ - struct fake_capture_plan *plan = (struct fake_capture_plan *)userdata; - (void)ctx; - *out = plan->sets[plan->calls]; - plan->calls++; - return 0; -}} - -int main(void) {{ - struct config cfg; - struct iface_context_set contexts; - struct fake_capture_plan plan; - struct service_record_set out; - - memset(&cfg, 0, sizeof(cfg)); - memset(&contexts, 0, sizeof(contexts)); - memset(&plan, 0, sizeof(plan)); - memset(&out, 0, sizeof(out)); - - snprintf(cfg.airport_wama, sizeof(cfg.airport_wama), "%s", "80:EA:96:E6:58:68"); - append_iface_context(&contexts, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - append_iface_context(&contexts, "bcmeth0", inet_addr("192.168.1.217"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - add_airport_record(&plan.sets[0], "timecapsule", "80-EA-96-E6-58-68"); - add_airport_record(&plan.sets[1], "timecapsule-wan", "80-EA-96-E6-58-68"); - - if (capture_mdns_snapshot_auto_with_provider(&out, &contexts, &cfg, 0, fake_capture_context, &plan) != 0) {{ - return 1; - }} - if (plan.calls != 2 || out.count != 2 || - strcmp(out.records[0].host_label, "timecapsule") != 0 || - strcmp(out.records[1].host_label, "timecapsule-wan") != 0) {{ - return 2; - }} - return 0; -}} -'''.format(mdns_source=mdns_source) - run = self._compile_and_run_c_helper(source, "mdns_auto_capture_first_match") - self.assertEqual(run.returncode, 0, run.stderr) - - def test_mdns_auto_capture_keeps_untrusted_records_for_later_filtering(self) -> None: - mdns_source = (REPO_ROOT / "build" / "mdns-advertiser.c").as_posix() - source = ''' -#include -#include -#define main mdns_advertiser_main -#include "{mdns_source}" -#undef main - -struct fake_capture_plan {{ - struct service_record_set sets[2]; - int calls; -}}; - -static void add_airport_record(struct service_record_set *set, const char *host, const char *mac) {{ - struct service_record *record; - char txt[80]; - - record = find_or_add_record(set, AIRPORT_SERVICE_TYPE, "James's AirPort Time Capsule"); - if (record == NULL) {{ - return; - }} - snprintf(record->host_label, sizeof(record->host_label), "%s", host); - snprintf(record->host_fqdn, sizeof(record->host_fqdn), "%s.local.", host); - record->port = AIRPORT_DEFAULT_PORT; - snprintf(txt, sizeof(txt), "waMA=%s", mac); - snprintf(record->txt[0], sizeof(record->txt[0]), "%s", txt); - record->txt_len[0] = (uint8_t)strlen(record->txt[0]); - record->txt_count = 1; -}} - -static int fake_capture_context(struct service_record_set *out, const struct iface_context *ctx, void *userdata) {{ - struct fake_capture_plan *plan = (struct fake_capture_plan *)userdata; - (void)ctx; - *out = plan->sets[plan->calls]; - plan->calls++; - return 0; -}} - -int main(void) {{ - struct config cfg; - struct iface_context_set contexts; - struct fake_capture_plan plan; - struct service_record_set out; - - memset(&cfg, 0, sizeof(cfg)); - memset(&contexts, 0, sizeof(contexts)); - memset(&plan, 0, sizeof(plan)); - memset(&out, 0, sizeof(out)); - - snprintf(cfg.airport_wama, sizeof(cfg.airport_wama), "%s", "80:EA:96:E6:58:68"); - append_iface_context(&contexts, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - append_iface_context(&contexts, "bcmeth0", inet_addr("192.168.1.217"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - add_airport_record(&plan.sets[0], "wrong-device", "00-11-22-33-44-55"); - add_airport_record(&plan.sets[1], "timecapsule", "80-EA-96-E6-58-68"); - - if (capture_mdns_snapshot_auto_with_provider(&out, &contexts, &cfg, 1, fake_capture_context, &plan) != 0) {{ - return 1; - }} - if (plan.calls != 2 || out.count != 2 || - strcmp(out.records[0].host_label, "wrong-device") != 0 || - strcmp(out.records[1].host_label, "timecapsule") != 0) {{ - return 2; - }} - return 0; -}} -'''.format(mdns_source=mdns_source) - run = self._compile_and_run_c_helper(source, "mdns_auto_capture_second_match") - self.assertEqual(run.returncode, 0, run.stderr) - - def test_mdns_context_announcement_uses_context_ipv4(self) -> None: - mdns_source = (REPO_ROOT / "build" / "mdns-advertiser.c").as_posix() - source = ''' -#include -#include -#include -#include - -static unsigned char captured_packet[1500]; -static size_t captured_len; - -ssize_t fake_sendto(int sockfd, const void *buf, size_t len, int flags, - const struct sockaddr *dest, socklen_t dest_len) {{ - (void)sockfd; - (void)flags; - (void)dest; - (void)dest_len; - memcpy(captured_packet, buf, len); - captured_len = len; - return (ssize_t)len; -}} - -int fake_setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen) {{ - (void)sockfd; - (void)level; - (void)optname; - (void)optval; - (void)optlen; - return 0; -}} - -#define sendto fake_sendto -#define setsockopt fake_setsockopt -#define main mdns_advertiser_main -#include "{mdns_source}" -#undef main -#undef setsockopt -#undef sendto - -static int contains_ipv4(const unsigned char *needle) {{ - size_t i; - for (i = 0; i + 4 <= captured_len; i++) {{ - if (memcmp(captured_packet + i, needle, 4) == 0) {{ - return 1; - }} - }} - return 0; -}} - -int main(void) {{ - struct config cfg; - struct iface_context ctx; - struct service_record_set snapshot; - struct sockaddr_in dest; - struct in_addr context_ip; - struct in_addr global_ip; - - memset(&cfg, 0, sizeof(cfg)); - memset(&ctx, 0, sizeof(ctx)); - memset(&snapshot, 0, sizeof(snapshot)); - memset(&dest, 0, sizeof(dest)); - - snprintf(cfg.instance_name, sizeof(cfg.instance_name), "%s", "TimeCapsule"); - snprintf(cfg.host_label, sizeof(cfg.host_label), "%s", "timecapsule"); - snprintf(cfg.host_fqdn, sizeof(cfg.host_fqdn), "%s", "timecapsule.local."); - snprintf(cfg.service_type, sizeof(cfg.service_type), "%s", "_smb._tcp.local."); - snprintf(cfg.device_info_service_type, sizeof(cfg.device_info_service_type), "%s", "_device-info._tcp.local."); - snprintf(cfg.adisk_service_type, sizeof(cfg.adisk_service_type), "%s", "_adisk._tcp.local."); - snprintf(cfg.airport_service_type, sizeof(cfg.airport_service_type), "%s", "_airport._tcp.local."); - cfg.port = 445; - cfg.ttl = 120; - cfg.ipv4_addr = inet_addr("192.168.1.217"); - - snprintf(ctx.name, sizeof(ctx.name), "%s", "bridge0"); - ctx.ipv4_addr = inet_addr("10.0.1.1"); - - dest.sin_family = AF_INET; - dest.sin_port = htons(5353); - dest.sin_addr.s_addr = inet_addr("224.0.0.251"); - - if (send_context_announcement(1, &ctx, &dest, &cfg, &snapshot, 0) != 0) {{ - return 1; - }} - context_ip.s_addr = inet_addr("10.0.1.1"); - global_ip.s_addr = inet_addr("192.168.1.217"); - if (!contains_ipv4((const unsigned char *)&context_ip.s_addr)) {{ - return 2; - }} - if (contains_ipv4((const unsigned char *)&global_ip.s_addr)) {{ - return 3; - }} - return 0; -}} -'''.format(mdns_source=mdns_source) - run = self._compile_and_run_c_helper(source, "mdns_context_announcement_ip") - self.assertEqual(run.returncode, 0, run.stderr) - - def test_mdns_auto_ip_takeover_uses_one_exclusive_socket(self) -> None: - mdns_source = (REPO_ROOT / "build" / "mdns-advertiser.c").as_posix() - source = r''' -#include -#include -#include -#include -#include -#include -#include - -static int fake_socket(int domain, int type, int protocol); -static int fake_setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); -static int fake_bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); -static int fake_close(int fd); -static int fake_system(const char *cmd); -static int fake_usleep(useconds_t usec); -static FILE *fake_popen(const char *cmd, const char *mode); -static char *fake_fgets(char *s, int size, FILE *stream); -static int fake_pclose(FILE *fp); - -#define socket fake_socket -#define setsockopt fake_setsockopt -#define bind fake_bind -#define close fake_close -#define system fake_system -#define usleep fake_usleep -#define popen fake_popen -#define fgets fake_fgets -#define pclose fake_pclose -#define main mdns_advertiser_main -#include "@MDNS_SOURCE@" -#undef main -#undef pclose -#undef fgets -#undef popen -#undef usleep -#undef system -#undef close -#undef bind -#undef setsockopt -#undef socket - -static int socket_calls; -static int socket_domain; -static int socket_type; -static int socket_protocol; -static int bind_calls; -static int bind_family; -static unsigned int bind_port; -static uint32_t bind_addr; -static int close_calls; -static int reuseaddr_sets; -static int reuseport_sets; -static int membership_sets; -static int drop_membership_sets; -static int outbound_sets; -static int system_calls; -static int fake_mdns_alive; -static int fake_fgets_served; -static int next_fd = 100; - -static void reset_fakes(void) { - socket_calls = 0; - socket_domain = 0; - socket_type = 0; - socket_protocol = 0; - bind_calls = 0; - bind_family = 0; - bind_port = 0; - bind_addr = 0xffffffffU; - close_calls = 0; - reuseaddr_sets = 0; - reuseport_sets = 0; - membership_sets = 0; - drop_membership_sets = 0; - outbound_sets = 0; - system_calls = 0; - fake_mdns_alive = 0; - fake_fgets_served = 0; - next_fd = 100; -} - -static int fake_socket(int domain, int type, int protocol) { - socket_calls++; - socket_domain = domain; - socket_type = type; - socket_protocol = protocol; - return next_fd++; -} - -static int fake_setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen) { - (void)sockfd; - (void)optval; - (void)optlen; - if (level == SOL_SOCKET && optname == SO_REUSEADDR) { - reuseaddr_sets++; - } -#ifdef SO_REUSEPORT - if (level == SOL_SOCKET && optname == SO_REUSEPORT) { - reuseport_sets++; - } -#endif - if (level == IPPROTO_IP && optname == IP_ADD_MEMBERSHIP) { - membership_sets++; - } -#ifdef IP_DROP_MEMBERSHIP - if (level == IPPROTO_IP && optname == IP_DROP_MEMBERSHIP) { - drop_membership_sets++; - } -#endif - if (level == IPPROTO_IP && optname == IP_MULTICAST_IF) { - outbound_sets++; - } - return 0; -} - -static int fake_bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen) { - const struct sockaddr_in *sin; - (void)sockfd; - bind_calls++; - if (addrlen >= sizeof(*sin) && addr != NULL) { - sin = (const struct sockaddr_in *)addr; - bind_family = sin->sin_family; - bind_port = (unsigned int)ntohs(sin->sin_port); - bind_addr = ntohl(sin->sin_addr.s_addr); - } - return 0; -} - -static int fake_close(int fd) { - (void)fd; - close_calls++; - return 0; -} - -static int fake_system(const char *cmd) { - (void)cmd; - system_calls++; - return 0; -} - -static int fake_usleep(useconds_t usec) { - (void)usec; - return 0; -} - -static FILE *fake_popen(const char *cmd, const char *mode) { - (void)cmd; - (void)mode; - if (!fake_mdns_alive) { - return NULL; - } - fake_fgets_served = 0; - return (FILE *)1; -} - -static char *fake_fgets(char *s, int size, FILE *stream) { - const char *line = "S mDNSResponder\n"; - (void)stream; - if (!fake_mdns_alive || fake_fgets_served || size <= 0) { - return NULL; - } - snprintf(s, (size_t)size, "%s", line); - fake_fgets_served = 1; - return s; -} - -static int fake_pclose(FILE *fp) { - (void)fp; - return 0; -} - -static void add_contexts(struct iface_context_set *contexts) { - memset(contexts, 0, sizeof(*contexts)); - append_iface_context(contexts, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - append_iface_context(contexts, "bcmeth0", inet_addr("192.168.1.217"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); -} - -int main(void) { - struct iface_context_set contexts; - struct sockaddr_in source; - int fd; - - add_contexts(&contexts); - if (contexts.count != 2) { - return 1; - } - memset(&source, 0, sizeof(source)); - source.sin_addr.s_addr = inet_addr("192.168.1.88"); - if (select_response_context(&contexts, &source) != &contexts.contexts[1]) { - return 6; - } - source.sin_addr.s_addr = inet_addr("172.16.0.10"); - if (select_response_context(&contexts, &source) != &contexts.contexts[0]) { - return 7; - } - - reset_fakes(); - fd = acquire_mdns_auto_socket(0, &contexts); - if (fd < 0 || socket_calls != 1 || bind_calls != 1 || close_calls != 0 || - reuseaddr_sets != 0 || reuseport_sets != 0 || membership_sets != 2 || outbound_sets != 1) { - return 2; - } - if (socket_domain != AF_INET || socket_type != SOCK_DGRAM || socket_protocol != 0 || - bind_family != AF_INET || bind_port != MDNS_PORT || bind_addr != INADDR_ANY) { - return 14; - } - - reset_fakes(); - fake_mdns_alive = 1; - fd = acquire_mdns_auto_socket(0, &contexts); - if (fd >= 0 || socket_calls != TAKEOVER_RETRY_COUNT * 2 || - close_calls != TAKEOVER_RETRY_COUNT * 2 || reuseaddr_sets != 0 || reuseport_sets != 0) { - return 3; - } - - reset_fakes(); - fd = acquire_mdns_auto_socket(1, &contexts); - if (fd < 0 || socket_calls != 1 || reuseaddr_sets != 1 || membership_sets != 2) { - return 4; - } - if (bind_family != AF_INET || bind_port != MDNS_PORT || bind_addr != INADDR_ANY) { - return 15; - } -#ifdef SO_REUSEPORT - if (reuseport_sets != 1) { - return 5; - } -#endif - - { - struct iface_context_set old_contexts; - struct iface_context_set new_contexts; - struct iface_context_set empty_contexts; - - memset(&old_contexts, 0, sizeof(old_contexts)); - memset(&new_contexts, 0, sizeof(new_contexts)); - memset(&empty_contexts, 0, sizeof(empty_contexts)); - append_iface_context(&old_contexts, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - append_iface_context(&old_contexts, "bcmeth0", inet_addr("192.168.1.217"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - append_iface_context(&new_contexts, "bridge0", inet_addr("10.0.1.1"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - append_iface_context(&new_contexts, "en1", inet_addr("192.168.50.2"), inet_addr("255.255.255.0"), IFF_UP | IFF_RUNNING); - - reset_fakes(); - if (prepare_mdns_auto_socket_for_contexts(55, &old_contexts, &new_contexts) != 0 || - socket_calls != 0 || bind_calls != 0 || close_calls != 0 || - membership_sets != 1 || outbound_sets != 1) { - return 8; - } - retire_mdns_auto_socket_contexts(55, &old_contexts, &new_contexts); - if (close_calls != 0) { - return 9; - } -#ifdef IP_DROP_MEMBERSHIP - if (drop_membership_sets != 1) { - return 10; - } -#endif - - reset_fakes(); - if (prepare_mdns_auto_socket_for_contexts(55, &old_contexts, &empty_contexts) != 0 || - socket_calls != 0 || bind_calls != 0 || close_calls != 0 || - membership_sets != 0 || outbound_sets != 0) { - return 11; - } - retire_mdns_auto_socket_contexts(55, &old_contexts, &empty_contexts); - if (close_calls != 0) { - return 12; - } -#ifdef IP_DROP_MEMBERSHIP - if (drop_membership_sets != 2) { - return 13; - } -#endif - } - - return 0; -} -'''.replace("@MDNS_SOURCE@", mdns_source) - run = self._compile_and_run_c_helper(source, "mdns_auto_ip_takeover_socket_shape") - self.assertEqual(run.returncode, 0, run.stderr) - def test_mdns_advertiser_extracts_service_type_from_arbitrary_instance_fqdn(self) -> None: if shutil.which("cc") is None: self.skipTest("cc not available") @@ -3456,7 +2786,6 @@ def test_mdns_advertiser_routes_qu_qm_and_mixed_query_responses(self) -> None: cfg->adisk_port = 9; cfg->airport_port = 5009; cfg->ttl = 120; - cfg->ipv4_addr = inet_addr("10.0.1.77"); } static void configure_addrs(struct sockaddr_in *mdns_dest, struct sockaddr_in *source) { @@ -3620,7 +2949,6 @@ def test_mdns_advertiser_routes_qu_qm_and_mixed_query_responses(self) -> None: static int run_route_cases(void) { struct config cfg; - struct iface_context response_ctx; struct link_context response_link; struct service_record_set snapshot; struct sockaddr_in mdns_dest; @@ -3629,11 +2957,14 @@ def test_mdns_advertiser_routes_qu_qm_and_mixed_query_responses(self) -> None: size_t query_len; configure_base(&cfg); - memset(&response_ctx, 0, sizeof(response_ctx)); - snprintf(response_ctx.name, sizeof(response_ctx.name), "%s", "bridge0"); - response_ctx.ipv4_addr = cfg.ipv4_addr; - response_ctx.netmask = inet_addr("255.255.255.0"); - link_context_from_iface_context(&response_link, &response_ctx); + memset(&response_link, 0, sizeof(response_link)); + snprintf(response_link.name, sizeof(response_link.name), "%s", "bridge0"); + response_link.flags = IFF_UP | IFF_RUNNING; + response_link.ipv4[0].addr = inet_addr("10.0.1.77"); + response_link.ipv4[0].netmask = inet_addr("255.255.255.0"); + response_link.ipv4_count = 1; + response_link.mdns_ipv4_transport = 1; + response_link.mdns_ipv4_transport_addr = response_link.ipv4[0].addr; memset(&snapshot, 0, sizeof(snapshot)); configure_addrs(&mdns_dest, &source); @@ -3794,17 +3125,17 @@ def test_mdns_advertiser_enumerates_dns_sd_service_types(self) -> None: cfg->adisk_port = 9; cfg->airport_port = 5009; cfg->ttl = 120; - cfg->ipv4_addr = inet_addr("10.0.1.77"); } static void configure_link(struct link_context *link, uint32_t ipv4_addr) { - struct iface_context ctx; - - memset(&ctx, 0, sizeof(ctx)); - snprintf(ctx.name, sizeof(ctx.name), "%s", "bridge0"); - ctx.ipv4_addr = ipv4_addr; - ctx.netmask = inet_addr("255.255.255.0"); - link_context_from_iface_context(link, &ctx); + memset(link, 0, sizeof(*link)); + snprintf(link->name, sizeof(link->name), "%s", "bridge0"); + link->flags = IFF_UP | IFF_RUNNING; + link->ipv4[0].addr = ipv4_addr; + link->ipv4[0].netmask = inet_addr("255.255.255.0"); + link->ipv4_count = 1; + link->mdns_ipv4_transport = 1; + link->mdns_ipv4_transport_addr = ipv4_addr; } static void configure_addrs(struct sockaddr_in *mdns_dest, struct sockaddr_in *source) { @@ -3944,7 +3275,7 @@ def test_mdns_advertiser_enumerates_dns_sd_service_types(self) -> None: size_t query_len; configure_base(&cfg); - configure_link(&link, cfg.ipv4_addr); + configure_link(&link, inet_addr("10.0.1.77")); configure_addrs(&mdns_dest, &source); memset(&snapshot, 0, sizeof(snapshot)); @@ -4107,17 +3438,17 @@ def test_mdns_advertiser_multicast_delay_and_unicast_hop_limits(self) -> None: snprintf(cfg->service_type, sizeof(cfg->service_type), "%s", "_smb._tcp.local."); cfg->port = 445; cfg->ttl = 120; - cfg->ipv4_addr = inet_addr("10.0.1.77"); } static void configure_link(struct link_context *link, uint32_t ipv4_addr) { - struct iface_context ctx; - - memset(&ctx, 0, sizeof(ctx)); - snprintf(ctx.name, sizeof(ctx.name), "%s", "bridge0"); - ctx.ipv4_addr = ipv4_addr; - ctx.netmask = inet_addr("255.255.255.0"); - link_context_from_iface_context(link, &ctx); + memset(link, 0, sizeof(*link)); + snprintf(link->name, sizeof(link->name), "%s", "bridge0"); + link->flags = IFF_UP | IFF_RUNNING; + link->ipv4[0].addr = ipv4_addr; + link->ipv4[0].netmask = inet_addr("255.255.255.0"); + link->ipv4_count = 1; + link->mdns_ipv4_transport = 1; + link->mdns_ipv4_transport_addr = ipv4_addr; link->ifindex = 5; } @@ -4146,7 +3477,7 @@ def test_mdns_advertiser_multicast_delay_and_unicast_hop_limits(self) -> None: size_t query_len; configure_base(&cfg); - configure_link(&link, cfg.ipv4_addr); + configure_link(&link, inet_addr("10.0.1.77")); memset(&snapshot, 0, sizeof(snapshot)); memset(&mdns_dest, 0, sizeof(mdns_dest)); mdns_dest.sin_family = AF_INET; @@ -4305,7 +3636,6 @@ def test_mdns_advertiser_diskless_answers_host_a_but_not_smb(self) -> None: cfg->adisk_port = 9; cfg->airport_port = 5009; cfg->ttl = 120; - cfg->ipv4_addr = inet_addr("10.0.1.77"); cfg->diskless = 1; } @@ -4365,7 +3695,6 @@ def test_mdns_advertiser_diskless_answers_host_a_but_not_smb(self) -> None: int main(void) { struct config cfg; - struct iface_context response_ctx; struct link_context response_link; struct service_record_set snapshot; struct sockaddr_in mdns_dest; @@ -4374,11 +3703,14 @@ def test_mdns_advertiser_diskless_answers_host_a_but_not_smb(self) -> None: size_t query_len; configure_base(&cfg); - memset(&response_ctx, 0, sizeof(response_ctx)); - snprintf(response_ctx.name, sizeof(response_ctx.name), "%s", "bridge0"); - response_ctx.ipv4_addr = cfg.ipv4_addr; - response_ctx.netmask = inet_addr("255.255.255.0"); - link_context_from_iface_context(&response_link, &response_ctx); + memset(&response_link, 0, sizeof(response_link)); + snprintf(response_link.name, sizeof(response_link.name), "%s", "bridge0"); + response_link.flags = IFF_UP | IFF_RUNNING; + response_link.ipv4[0].addr = inet_addr("10.0.1.77"); + response_link.ipv4[0].netmask = inet_addr("255.255.255.0"); + response_link.ipv4_count = 1; + response_link.mdns_ipv4_transport = 1; + response_link.mdns_ipv4_transport_addr = response_link.ipv4[0].addr; memset(&snapshot, 0, sizeof(snapshot)); memset(&mdns_dest, 0, sizeof(mdns_dest)); mdns_dest.sin_family = AF_INET; @@ -4471,7 +3803,6 @@ def test_mdns_advertiser_suppresses_fresh_known_answer_a_records(self) -> None: snprintf(cfg->airport_service_type, sizeof(cfg->airport_service_type), "%s", "_airport._tcp.local."); cfg->port = 445; cfg->ttl = 120; - cfg->ipv4_addr = inet_addr("10.0.1.77"); } static size_t make_query_with_known_a_pair(unsigned char *packet, const struct config *cfg, @@ -4553,21 +3884,25 @@ def test_mdns_advertiser_suppresses_fresh_known_answer_a_records(self) -> None: int main(void) { struct config cfg; - struct iface_context response_ctx; struct link_context response_link; struct service_record_set snapshot; struct sockaddr_in mdns_dest; struct sockaddr_in source; unsigned char query[BUF_SIZE]; size_t query_len; + uint32_t primary_addr; uint32_t link_local_addr; configure_base(&cfg); - memset(&response_ctx, 0, sizeof(response_ctx)); - snprintf(response_ctx.name, sizeof(response_ctx.name), "%s", "bridge0"); - response_ctx.ipv4_addr = cfg.ipv4_addr; - response_ctx.netmask = inet_addr("255.255.255.0"); - link_context_from_iface_context(&response_link, &response_ctx); + primary_addr = inet_addr("10.0.1.77"); + memset(&response_link, 0, sizeof(response_link)); + snprintf(response_link.name, sizeof(response_link.name), "%s", "bridge0"); + response_link.flags = IFF_UP | IFF_RUNNING; + response_link.ipv4[0].addr = primary_addr; + response_link.ipv4[0].netmask = inet_addr("255.255.255.0"); + response_link.ipv4_count = 1; + response_link.mdns_ipv4_transport = 1; + response_link.mdns_ipv4_transport_addr = primary_addr; memset(&snapshot, 0, sizeof(snapshot)); memset(&mdns_dest, 0, sizeof(mdns_dest)); mdns_dest.sin_family = AF_INET; @@ -4579,7 +3914,7 @@ def test_mdns_advertiser_suppresses_fresh_known_answer_a_records(self) -> None: source.sin_addr.s_addr = inet_addr("10.0.1.42"); reset_captures(); - query_len = make_query_with_known_a(query, &cfg, 100, cfg.ipv4_addr); + query_len = make_query_with_known_a(query, &cfg, 100, primary_addr); if (query_len == 0 || handle_query(1, query, query_len, &mdns_dest, &source, &cfg, &response_link, &snapshot, 0) != 0 || captured_count != 0) { @@ -4595,7 +3930,7 @@ def test_mdns_advertiser_suppresses_fresh_known_answer_a_records(self) -> None: response_link.ipv4_count++; reset_captures(); - query_len = make_query_with_known_a(query, &cfg, 100, cfg.ipv4_addr); + query_len = make_query_with_known_a(query, &cfg, 100, primary_addr); if (query_len == 0 || handle_query(1, query, query_len, &mdns_dest, &source, &cfg, &response_link, &snapshot, 0) != 0 || captured_count != 1 || @@ -4604,7 +3939,7 @@ def test_mdns_advertiser_suppresses_fresh_known_answer_a_records(self) -> None: } reset_captures(); - query_len = make_query_with_known_a_pair(query, &cfg, 100, cfg.ipv4_addr, 1, link_local_addr); + query_len = make_query_with_known_a_pair(query, &cfg, 100, primary_addr, 1, link_local_addr); if (query_len == 0 || handle_query(1, query, query_len, &mdns_dest, &source, &cfg, &response_link, &snapshot, 0) != 0 || captured_count != 0) { @@ -4612,7 +3947,7 @@ def test_mdns_advertiser_suppresses_fresh_known_answer_a_records(self) -> None: } reset_captures(); - query_len = make_query_with_known_a(query, &cfg, 10, cfg.ipv4_addr); + query_len = make_query_with_known_a(query, &cfg, 10, primary_addr); if (query_len == 0 || handle_query(1, query, query_len, &mdns_dest, &source, &cfg, &response_link, &snapshot, 0) != 0 || captured_count != 1 || @@ -4689,7 +4024,6 @@ def test_mdns_advertiser_defers_tc_and_matches_structured_known_answers(self) -> snprintf(cfg->airport_service_type, sizeof(cfg->airport_service_type), "%s", "_airport._tcp.local."); cfg->port = 445; cfg->ttl = 120; - cfg->ipv4_addr = inet_addr("10.0.1.77"); } static int count_rr_type(const unsigned char *packet, size_t packet_len, unsigned short want_type) { @@ -4827,7 +4161,6 @@ def test_mdns_advertiser_defers_tc_and_matches_structured_known_answers(self) -> int main(void) { struct config cfg; - struct iface_context response_ctx; struct link_context response_link; struct service_record_set snapshot; struct sockaddr_in mdns_dest; @@ -4835,17 +4168,22 @@ def test_mdns_advertiser_defers_tc_and_matches_structured_known_answers(self) -> unsigned char query[BUF_SIZE]; size_t query_len; char instance_fqdn[MAX_NAME]; + uint32_t primary_addr; uint32_t link_local_addr; configure_base(&cfg); + primary_addr = inet_addr("10.0.1.77"); if (build_instance_fqdn(instance_fqdn, sizeof(instance_fqdn), cfg.instance_name, cfg.service_type) != 0) { return 1; } - memset(&response_ctx, 0, sizeof(response_ctx)); - snprintf(response_ctx.name, sizeof(response_ctx.name), "%s", "bridge0"); - response_ctx.ipv4_addr = cfg.ipv4_addr; - response_ctx.netmask = inet_addr("255.255.255.0"); - link_context_from_iface_context(&response_link, &response_ctx); + memset(&response_link, 0, sizeof(response_link)); + snprintf(response_link.name, sizeof(response_link.name), "%s", "bridge0"); + response_link.flags = IFF_UP | IFF_RUNNING; + response_link.ipv4[0].addr = primary_addr; + response_link.ipv4[0].netmask = inet_addr("255.255.255.0"); + response_link.ipv4_count = 1; + response_link.mdns_ipv4_transport = 1; + response_link.mdns_ipv4_transport_addr = primary_addr; link_local_addr = inet_addr("169.254.44.55"); response_link.ipv4[response_link.ipv4_count].addr = link_local_addr; response_link.ipv4[response_link.ipv4_count].netmask = ipv4_link_local_netmask(); @@ -4869,7 +4207,7 @@ def test_mdns_advertiser_defers_tc_and_matches_structured_known_answers(self) -> !g_deferred_response.active) { return 2; } - query_len = make_known_a_only(query, &cfg, cfg.ipv4_addr); + query_len = make_known_a_only(query, &cfg, primary_addr); if (query_len == 0 || handle_query(1, query, query_len, &mdns_dest, &source, &cfg, &response_link, &snapshot, 0) != 0 || captured_count != 1 || @@ -4976,7 +4314,6 @@ def test_mdns_advertiser_query_response_preserves_snapshot_suppression(self) -> cfg->adisk_port = 9; cfg->airport_port = 5009; cfg->ttl = 120; - cfg->ipv4_addr = inet_addr("10.0.1.77"); } static void configure_addrs(struct sockaddr_in *mdns_dest, struct sockaddr_in *source) { @@ -5071,7 +4408,6 @@ def test_mdns_advertiser_query_response_preserves_snapshot_suppression(self) -> int main(void) { struct config cfg; - struct iface_context response_ctx; struct link_context response_link; struct service_record_set snapshot; struct sockaddr_in mdns_dest; @@ -5080,11 +4416,14 @@ def test_mdns_advertiser_query_response_preserves_snapshot_suppression(self) -> size_t query_len; configure_base(&cfg); - memset(&response_ctx, 0, sizeof(response_ctx)); - snprintf(response_ctx.name, sizeof(response_ctx.name), "%s", "bridge0"); - response_ctx.ipv4_addr = cfg.ipv4_addr; - response_ctx.netmask = inet_addr("255.255.255.0"); - link_context_from_iface_context(&response_link, &response_ctx); + memset(&response_link, 0, sizeof(response_link)); + snprintf(response_link.name, sizeof(response_link.name), "%s", "bridge0"); + response_link.flags = IFF_UP | IFF_RUNNING; + response_link.ipv4[0].addr = inet_addr("10.0.1.77"); + response_link.ipv4[0].netmask = inet_addr("255.255.255.0"); + response_link.ipv4_count = 1; + response_link.mdns_ipv4_transport = 1; + response_link.mdns_ipv4_transport_addr = response_link.ipv4[0].addr; memset(&snapshot, 0, sizeof(snapshot)); configure_addrs(&mdns_dest, &source); add_snapshot_record(&snapshot, "_airport._tcp.local.", "Alton Time Capsule", "Alton-Time-Capsule", 5009, "syAP=116"); @@ -5262,7 +4601,6 @@ def test_mdns_advertiser_splits_snapshot_announcements_and_keeps_managed_device_ int main(void) {{ struct config cfg; - struct iface_context response_ctx; struct link_context response_link; struct service_record_set snapshot; struct sockaddr_in dest; @@ -5283,20 +4621,21 @@ def test_mdns_advertiser_splits_snapshot_announcements_and_keeps_managed_device_ snprintf(cfg.adisk_service_type, sizeof(cfg.adisk_service_type), "%s", "_adisk._tcp.local."); snprintf(cfg.airport_service_type, sizeof(cfg.airport_service_type), "%s", "_airport._tcp.local."); snprintf(cfg.device_model, sizeof(cfg.device_model), "%s", "TimeCapsule8,119"); - snprintf(cfg.adisk_share_name, sizeof(cfg.adisk_share_name), "%s", "Data"); - snprintf(cfg.adisk_disk_key, sizeof(cfg.adisk_disk_key), "%s", "dk2"); - snprintf(cfg.adisk_uuid, sizeof(cfg.adisk_uuid), "%s", "c4f673b8-c422-4da7-92a1-54bffe406af2"); - snprintf(cfg.adisk_disk_advf, sizeof(cfg.adisk_disk_advf), "%s", "0x82"); snprintf(cfg.adisk_sys_wama, sizeof(cfg.adisk_sys_wama), "%s", "80:EA:96:E6:58:68"); cfg.port = 445; cfg.adisk_port = 9; cfg.ttl = 120; - cfg.ipv4_addr = inet_addr("192.168.1.217"); - memset(&response_ctx, 0, sizeof(response_ctx)); - snprintf(response_ctx.name, sizeof(response_ctx.name), "%s", "bridge0"); - response_ctx.ipv4_addr = cfg.ipv4_addr; - response_ctx.netmask = inet_addr("255.255.255.0"); - link_context_from_iface_context(&response_link, &response_ctx); + if (add_adisk_disk_config(&cfg, "Data", "dk2", "c4f673b8-c422-4da7-92a1-54bffe406af2", "0x82") != 0) {{ + return 1; + }} + memset(&response_link, 0, sizeof(response_link)); + snprintf(response_link.name, sizeof(response_link.name), "%s", "bridge0"); + response_link.flags = IFF_UP | IFF_RUNNING; + response_link.ipv4[0].addr = inet_addr("192.168.1.217"); + response_link.ipv4[0].netmask = inet_addr("255.255.255.0"); + response_link.ipv4_count = 1; + response_link.mdns_ipv4_transport = 1; + response_link.mdns_ipv4_transport_addr = response_link.ipv4[0].addr; memset(&snapshot, 0, sizeof(snapshot)); @@ -5481,7 +4820,6 @@ def test_mdns_advertiser_diskless_replays_unsuppressed_snapshot_records(self) -> int main(void) { struct config cfg; - struct iface_context response_ctx; struct link_context response_link; struct service_record_set snapshot; struct sockaddr_in dest; @@ -5510,13 +4848,15 @@ def test_mdns_advertiser_diskless_replays_unsuppressed_snapshot_records(self) -> cfg.adisk_port = 9; cfg.airport_port = 5009; cfg.ttl = 120; - cfg.ipv4_addr = inet_addr("10.0.1.77"); cfg.diskless = 1; - memset(&response_ctx, 0, sizeof(response_ctx)); - snprintf(response_ctx.name, sizeof(response_ctx.name), "%s", "bridge0"); - response_ctx.ipv4_addr = cfg.ipv4_addr; - response_ctx.netmask = inet_addr("255.255.255.0"); - link_context_from_iface_context(&response_link, &response_ctx); + memset(&response_link, 0, sizeof(response_link)); + snprintf(response_link.name, sizeof(response_link.name), "%s", "bridge0"); + response_link.flags = IFF_UP | IFF_RUNNING; + response_link.ipv4[0].addr = inet_addr("10.0.1.77"); + response_link.ipv4[0].netmask = inet_addr("255.255.255.0"); + response_link.ipv4_count = 1; + response_link.mdns_ipv4_transport = 1; + response_link.mdns_ipv4_transport_addr = response_link.ipv4[0].addr; if (add_adisk_disk_config(&cfg, "Data", "dk2", "12345678-1234-1234-1234-123456789012", "0x82") != 0) { return 1; } @@ -5643,20 +4983,35 @@ def test_nbns_advertiser_retries_interrupted_sendto(self) -> None: run = self._compile_and_run_c_helper(source, "nbns_sendto_eintr") self.assertEqual(run.returncode, 0, run.stderr) - def test_nbns_advertiser_rejects_auto_ip_with_explicit_ipv4(self) -> None: + def test_nbns_advertiser_rejects_removed_legacy_cli_modes(self) -> None: if shutil.which("cc") is None: self.skipTest("cc not available") with tempfile.TemporaryDirectory() as tmpdir: bin_path = self._compile_nbns_advertiser_binary(Path(tmpdir)) - run = subprocess.run( - [str(bin_path), "--name", "TimeCapsule", "--auto-ip", "--ipv4", "192.168.1.217"], - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(run.returncode, 2) - self.assertIn("mutually exclusive", run.stderr) + runs = [ + subprocess.run( + [str(bin_path), "--name", "TimeCapsule", "--ipv4", "192.168.1.217"], + capture_output=True, + text=True, + check=False, + ), + subprocess.run( + [str(bin_path), "--name", "TimeCapsule", "--auto-ip", "--ttl", "30"], + capture_output=True, + text=True, + check=False, + ), + subprocess.run( + [str(bin_path), "--check-auto-ip"], + capture_output=True, + text=True, + check=False, + ), + ] + for run in runs: + self.assertEqual(run.returncode, 2) + self.assertIn("Usage:", run.stderr) def test_nbns_advertiser_version_prints_version_code(self) -> None: if shutil.which("cc") is None: @@ -5669,22 +5024,7 @@ def test_nbns_advertiser_version_prints_version_code(self) -> None: self.assertEqual(run.stdout, "2104\n") self.assertEqual(run.stderr, "") - def test_nbns_advertiser_rejects_ttl_with_trailing_garbage(self) -> None: - if shutil.which("cc") is None: - self.skipTest("cc not available") - - with tempfile.TemporaryDirectory() as tmpdir: - bin_path = self._compile_nbns_advertiser_binary(Path(tmpdir)) - run = subprocess.run( - [str(bin_path), "--name", "TimeCapsule", "--ipv4", "192.168.1.217", "--ttl", "30junk"], - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(run.returncode, 2) - self.assertIn("ttl must be between 1 and 86400", run.stderr) - - def test_nbns_advertiser_usage_reports_120_second_default_ttl(self) -> None: + def test_nbns_advertiser_usage_reports_auto_ip_only(self) -> None: if shutil.which("cc") is None: self.skipTest("cc not available") @@ -5693,7 +5033,11 @@ def test_nbns_advertiser_usage_reports_120_second_default_ttl(self) -> None: run = subprocess.run([str(bin_path), "--help"], capture_output=True, text=True, check=False) self.assertEqual(run.returncode, 0) - self.assertIn("Record TTL (default: 120)", run.stderr) + self.assertIn("Usage:", run.stderr) + self.assertIn("--auto-ip", run.stderr) + self.assertNotIn("--ipv4", run.stderr) + self.assertNotIn("--ttl", run.stderr) + self.assertNotIn("--check-auto-ip", run.stderr) def test_nbns_advertiser_builds_rfc_query_and_status_responses(self) -> None: nbns_source = (REPO_ROOT / "build" / "nbns-advertiser.c").as_posix() @@ -6222,7 +5566,7 @@ def test_nbns_advertiser_rejects_overlong_name_before_truncation(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: bin_path = self._compile_nbns_advertiser_binary(Path(tmpdir)) run = subprocess.run( - [str(bin_path), "--name", "ABCDEFGHIJKLMNOP", "--ipv4", "192.168.1.217"], + [str(bin_path), "--name", "ABCDEFGHIJKLMNOP", "--auto-ip"], capture_output=True, text=True, check=False, From 09e9bb53c95d7631b7455b91ab808ad20c26a01d Mon Sep 17 00:00:00 2001 From: James Chang Date: Mon, 25 May 2026 22:24:21 -0700 Subject: [PATCH 037/129] Clean up unused runtime --- src/timecapsulesmb/cli/runtime.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/timecapsulesmb/cli/runtime.py b/src/timecapsulesmb/cli/runtime.py index de1fab3f..8a119678 100644 --- a/src/timecapsulesmb/cli/runtime.py +++ b/src/timecapsulesmb/cli/runtime.py @@ -2,7 +2,6 @@ import argparse import json -import math from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional @@ -30,7 +29,6 @@ read_interface_ipv4_addrs_conn, ) from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS -from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC from timecapsulesmb.services import runtime as service_runtime from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy @@ -68,16 +66,6 @@ def non_negative_int_arg(value: str) -> int: 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", @@ -92,16 +80,6 @@ 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) From 07a50667f086b783a0e1ca6b411809102648011e Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 00:35:14 -0700 Subject: [PATCH 038/129] Add gui support for ipv6 --- .../Backend/OperationParams.swift | 6 +- .../Policies/DeviceEndpointPolicy.swift | 189 ++++++++++++++++++ .../Policies/SMBAddressPolicy.swift | 42 +--- .../Profiles/ConfiguredDeviceModels.swift | 58 ++++-- .../Profiles/DeviceNetworkIdentity.swift | 175 ++++++++++++++++ .../Profiles/DeviceProfile.swift | 171 +++++++++++++--- .../Profiles/DeviceRegistryStore.swift | 74 +++++-- .../Resources/en.lproj/Localizable.strings | 7 +- .../Views/AddDevice/AddDeviceView.swift | 5 +- .../Views/Dashboard/OverviewTab.swift | 2 +- .../Views/Shell/DeviceListOverviewView.swift | 4 +- .../Views/Shell/SidebarView.swift | 2 +- .../Workflows/AddDeviceFlowStore.swift | 11 +- .../DashboardOverviewPresentation.swift | 8 +- .../DeviceDiscoveryMonitorStore.swift | 2 +- .../AddDeviceFlowStoreTests.swift | 60 ++++++ .../DeviceProfileTests.swift | 42 +++- .../DeviceRegistryStoreTests.swift | 13 ++ .../PendingConfirmationTests.swift | 10 + .../SMBAddressPolicyTests.swift | 18 +- .../StoreTestSupport.swift | 55 +++-- 21 files changed, 811 insertions(+), 143 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceNetworkIdentity.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift index 9c3a710b..36e67f0c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift @@ -11,11 +11,7 @@ struct RepairXattrsOptions: Equatable { 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)" + DeviceEndpointPolicy.rootSSHTarget(host) } private static func withCredentials(_ params: [String: JSONValue], password: String) -> [String: JSONValue] { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift new file mode 100644 index 00000000..9568c83a --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift @@ -0,0 +1,189 @@ +import Darwin +import Foundation + +enum DeviceEndpointPolicy { + static func rootSSHTarget(_ target: String) -> String { + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("@") else { + return trimmed + } + return "root@\(trimmed)" + } + + static func hostComponent(_ value: String?) -> String? { + guard var candidate = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !candidate.isEmpty else { + return nil + } + + if let url = URLComponents(string: candidate), let host = url.host, !host.isEmpty { + candidate = host + } else { + candidate = candidate.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? candidate + candidate = candidate.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) + .last + .map(String.init) ?? candidate + if candidate.hasPrefix("["), + let end = candidate.firstIndex(of: "]") { + candidate = String(candidate[candidate.index(after: candidate.startIndex).. String { + guard let host = hostComponent(value) else { + return "" + } + if let address = DeviceNetworkAddress(value: host, source: .configured) { + return "\(address.family.rawValue):\(address.normalizedValue)" + } + return "hostname:\(host.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased())" + } + + static func preferredSetupTarget(for identity: DeviceNetworkIdentity) -> String? { + if let address = identity.addresses.first(where: { $0.family == .ipv4 && $0.scope == .regular }) { + return address.value + } + if let address = identity.addresses.first(where: { $0.family == .ipv6 && $0.scope == .regular }) { + return address.value + } + if let address = identity.addresses.first(where: { $0.family == .ipv6 }) { + return address.value + } + if let hostname = normalizedHostname(identity.hostname) { + return hostname + } + if let address = identity.addresses.first(where: { $0.family == .ipv4 }) { + return address.value + } + return hostComponent(identity.configuredSSHTarget) + } + + static func displayTarget(for identity: DeviceNetworkIdentity) -> String { + if let hostname = normalizedHostname(identity.hostname) { + return hostname + } + if let target = preferredSetupTarget(for: identity) { + return target + } + return hostComponent(identity.configuredSSHTarget) + ?? identity.configuredSSHTarget.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func normalizedHostname(_ value: String?) -> String? { + guard let host = hostComponent(value), + addressFamily(for: host) == nil else { + return nil + } + let normalized = host.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalized.isEmpty ? nil : normalized + } + + static func addressFamily(for value: String) -> NetworkAddressFamily? { + if inetPton(AF_INET, value) { + return .ipv4 + } + if inetPton(AF_INET6, ipv6LiteralForParsing(value)) { + return .ipv6 + } + return nil + } + + static func addressScope(value: String, family: NetworkAddressFamily) -> NetworkAddressScope { + switch family { + case .ipv4: + if value.hasPrefix("169.254.") { + return .linkLocal + } + if value.hasPrefix("127.") { + return .loopback + } + return .regular + case .ipv6: + let literal = ipv6LiteralForParsing(value).lowercased() + if literal == "::1" { + return .loopback + } + let firstHextet = literal.split(separator: ":", maxSplits: 1).first.map(String.init) ?? "" + if let value = Int(firstHextet, radix: 16), (value & 0xffc0) == 0xfe80 { + return .linkLocal + } + return .regular + } + } + + static func normalizedAddressValue(_ value: String, family: NetworkAddressFamily) -> String { + switch family { + case .ipv4: + return value + case .ipv6: + return value.lowercased() + } + } + + static func uniqueAddresses(_ addresses: [DeviceNetworkAddress]) -> [DeviceNetworkAddress] { + var seen: Set = [] + var ordered: [DeviceNetworkAddress] = [] + for address in addresses { + if seen.insert(address.identityKey).inserted { + ordered.append(address) + } + } + return ordered + } + + static func addressSummary(_ addresses: [DeviceNetworkAddress]) -> String { + let regular = addresses.filter { $0.scope == .regular } + let prioritized = regular.isEmpty ? addresses : regular + return prioritized + .map { "\($0.family.title) \($0.value)" + ($0.scope == .linkLocal ? " link-local" : "") } + .joined(separator: " ") + } + + static func smbURL(host: String, account: String?) -> URL? { + let renderedHost: String + if addressFamily(for: host) == .ipv6 { + renderedHost = "[\(host)]" + } else if let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) { + renderedHost = encodedHost + } else { + return nil + } + + let accountPrefix: String + if let account = account?.trimmingCharacters(in: .whitespacesAndNewlines), + !account.isEmpty, + let encodedAccount = account.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) { + accountPrefix = "\(encodedAccount)@" + } else { + accountPrefix = "" + } + return URL(string: "smb://\(accountPrefix)\(renderedHost)") + } + + private static func ipv6LiteralForParsing(_ value: String) -> String { + value.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + .split(separator: "%", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? value + } + + private static func inetPton(_ family: Int32, _ value: String) -> Bool { + var storage = sockaddr_storage() + return withUnsafeMutablePointer(to: &storage) { pointer in + pointer.withMemoryRebound(to: UInt8.self, capacity: MemoryLayout.size) { raw in + value.withCString { cString in + inet_pton(family, cString, raw) == 1 + } + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift index 44e0c67e..c97b7076 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift @@ -15,6 +15,12 @@ enum SMBAddressPolicy { if let hostname = normalizedAddressHost(profile.hostname) { return hostname } + if let address = profile.network.addresses.first(where: { $0.scope == .regular }) { + return address.value + } + if let address = profile.network.addresses.first { + return address.value + } return normalizedAddressHost(profile.host) } @@ -22,7 +28,7 @@ enum SMBAddressPolicy { unique([ normalizedAddressHost(profile.hostname), normalizedAddressHost(profile.host) - ]) + ] + profile.network.addresses.map { normalizedAddressHost($0.value) }) } private static func bonjourSMBServiceHost(for profile: DeviceProfile) -> String? { @@ -48,41 +54,11 @@ enum SMBAddressPolicy { } private static func url(host: String, account: String?) -> URL? { - guard let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { - return nil - } - let accountPrefix: String - if let account = account?.trimmingCharacters(in: .whitespacesAndNewlines), - !account.isEmpty, - let encodedAccount = account.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) { - accountPrefix = "\(encodedAccount)@" - } else { - accountPrefix = "" - } - return URL(string: "smb://\(accountPrefix)\(encodedHost)") + DeviceEndpointPolicy.smbURL(host: host, account: account) } private static func normalizedAddressHost(_ value: String?) -> String? { - guard var candidate = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !candidate.isEmpty else { - return nil - } - - if let parsedURL = URL(string: candidate), let parsedHost = parsedURL.host, !parsedHost.isEmpty { - candidate = parsedHost - } else { - candidate = candidate.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) - .first - .map(String.init) ?? candidate - candidate = candidate.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) - .last - .map(String.init) ?? candidate - } - - let normalized = candidate - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: ".")) - return normalized.isEmpty ? nil : normalized + DeviceEndpointPolicy.hostComponent(value) } private static func unique(_ values: [String?]) -> [String] { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift index 9dacb811..fd26912f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift @@ -3,19 +3,37 @@ import Foundation struct DiscoveredDevice: Identifiable, Equatable { let id: String let name: String - let host: String + let connectionTarget: String + let sshHost: String? let hostname: String - let addresses: [String] + let networkAddresses: [DeviceNetworkAddress] let syap: String? let model: String? let rawRecord: JSONValue + var host: String { connectionTarget } + var addresses: [String] { networkAddresses.map(\.value) } + var addressSummary: String { DeviceEndpointPolicy.addressSummary(networkAddresses) } + init(payload: DiscoveredDevicePayload, index: Int) { + let addresses = Self.networkAddresses(ipv4: payload.ipv4, ipv6: payload.ipv6, fallback: payload.addresses) + let sshHost = Self.nonEmpty(payload.sshHost) + let backendTarget = sshHost.flatMap(DeviceEndpointPolicy.hostComponent) + ?? DeviceEndpointPolicy.hostComponent(payload.host) + let identity = DeviceNetworkIdentity( + configuredSSHTarget: backendTarget ?? "", + hostname: payload.hostname, + bonjourName: payload.name, + bonjourFullname: payload.fullname, + addresses: addresses + ) + self.id = payload.id.isEmpty ? "discovered-\(index)" : payload.id self.name = payload.name.isEmpty ? (payload.hostname.isEmpty ? "AirPort Device" : payload.hostname) : payload.name - self.host = payload.host + self.connectionTarget = backendTarget ?? identity.preferredSetupTarget + self.sshHost = sshHost self.hostname = payload.hostname - self.addresses = payload.addresses.isEmpty ? payload.ipv4 + payload.ipv6 : payload.addresses + self.networkAddresses = identity.addresses self.syap = Self.nonEmpty(payload.syap) self.model = Self.nonEmpty(payload.model) ?? Self.recordProperty(payload.selectedRecord, keys: ["model", "am"]) self.rawRecord = payload.selectedRecord @@ -35,11 +53,21 @@ struct DiscoveredDevice: Identifiable, Equatable { .filter { !$0.isEmpty } .joined(separator: "|") + let resolvedName = record.name.isEmpty ? (record.hostname.isEmpty ? "AirPort Device" : record.hostname) : record.name self.id = stableID.isEmpty ? "discovered-\(index)" : stableID - self.name = record.name.isEmpty ? (record.hostname.isEmpty ? "AirPort Device" : record.hostname) : record.name + self.name = resolvedName self.hostname = record.hostname - self.addresses = record.ipv4 + record.ipv6 - self.host = Self.displayHost(record) + let addresses = Self.networkAddresses(ipv4: record.ipv4, ipv6: record.ipv6, fallback: []) + self.networkAddresses = addresses + let identity = DeviceNetworkIdentity( + configuredSSHTarget: "", + hostname: record.hostname, + bonjourName: resolvedName, + bonjourFullname: record.fullname, + addresses: addresses + ) + self.connectionTarget = identity.preferredSetupTarget + self.sshHost = DeviceEndpointPolicy.rootSSHTarget(connectionTarget) self.syap = Self.nonEmpty(record.properties["syAP"] ?? record.properties["syap"]) self.model = Self.nonEmpty(record.properties["model"] ?? record.properties["am"]) self.rawRecord = record.jsonValue @@ -49,13 +77,6 @@ struct DiscoveredDevice: Identifiable, Equatable { Self.nonEmpty(model) ?? "" } - private static func displayHost(_ record: BonjourResolvedServicePayload) -> String { - if let address = record.ipv4.first ?? record.ipv6.first { - return address - } - return record.hostname - } - private static func recordProperty(_ record: JSONValue, keys: [String]) -> String? { guard case .object(let values) = record, case .object(let properties)? = values["properties"] else { return nil @@ -74,6 +95,15 @@ struct DiscoveredDevice: Identifiable, Equatable { } return trimmed } + + private static func networkAddresses(ipv4: [String], ipv6: [String], fallback: [String]) -> [DeviceNetworkAddress] { + var addresses = ipv4.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + addresses += ipv6.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + if addresses.isEmpty { + addresses = fallback.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + } + return DeviceEndpointPolicy.uniqueAddresses(addresses) + } } struct ConfiguredDeviceState: Equatable { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceNetworkIdentity.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceNetworkIdentity.swift new file mode 100644 index 00000000..ec66016c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceNetworkIdentity.swift @@ -0,0 +1,175 @@ +import Foundation + +enum NetworkAddressFamily: String, Codable, Equatable { + case ipv4 + case ipv6 + + var title: String { + switch self { + case .ipv4: + return "IPv4" + case .ipv6: + return "IPv6" + } + } +} + +enum NetworkAddressScope: String, Codable, Equatable { + case regular + case linkLocal + case loopback +} + +enum DeviceAddressSource: String, Codable, Equatable { + case bonjour + case configured + case manual +} + +struct DeviceNetworkAddress: Codable, Equatable, Identifiable { + var id: String { identityKey } + + var value: String + var family: NetworkAddressFamily + var scope: NetworkAddressScope + var source: DeviceAddressSource + + var normalizedValue: String { + DeviceEndpointPolicy.normalizedAddressValue(value, family: family) + } + + var identityKey: String { + "\(family.rawValue):\(normalizedValue)" + } + + init?(value: String, source: DeviceAddressSource) { + guard let host = DeviceEndpointPolicy.hostComponent(value), + let family = DeviceEndpointPolicy.addressFamily(for: host) else { + return nil + } + self.value = DeviceEndpointPolicy.normalizedAddressValue(host, family: family) + self.family = family + self.scope = DeviceEndpointPolicy.addressScope(value: self.value, family: family) + self.source = source + } +} + +struct DeviceNetworkIdentity: Codable, Equatable { + var configuredSSHTarget: String + var hostname: String? + var bonjourName: String? + var bonjourFullname: String? + var addresses: [DeviceNetworkAddress] + + init( + configuredSSHTarget: String, + hostname: String? = nil, + bonjourName: String? = nil, + bonjourFullname: String? = nil, + addresses: [DeviceNetworkAddress] = [] + ) { + self.configuredSSHTarget = configuredSSHTarget + self.hostname = Self.normalizedOptional(hostname) + self.bonjourName = Self.normalizedOptional(bonjourName) + self.bonjourFullname = Self.normalizedOptional(bonjourFullname) + self.addresses = DeviceEndpointPolicy.uniqueAddresses(addresses) + appendConfiguredTargetAddress() + } + + var configuredHost: String { + DeviceEndpointPolicy.hostComponent(configuredSSHTarget) + ?? configuredSSHTarget.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var normalizedConfiguredHost: String { + DeviceEndpointPolicy.normalizedHostKey(configuredSSHTarget) + } + + var preferredSetupTarget: String { + DeviceEndpointPolicy.preferredSetupTarget(for: self) ?? configuredHost + } + + var displayTarget: String { + DeviceEndpointPolicy.displayTarget(for: self) + } + + var addressValues: [String] { + addresses.map(\.value) + } + + var addressSummary: String { + DeviceEndpointPolicy.addressSummary(addresses) + } + + var normalizedHostname: String { + DeviceEndpointPolicy.normalizedHostname(hostname)?.lowercased() ?? "" + } + + var addressKeys: Set { + Set(addresses.map(\.identityKey)) + } + + func matches(_ other: DeviceNetworkIdentity) -> Bool { + if let leftFullname = Self.normalizedOptional(bonjourFullname)?.lowercased(), + let rightFullname = Self.normalizedOptional(other.bonjourFullname)?.lowercased(), + leftFullname == rightFullname { + return true + } + if !normalizedConfiguredHost.isEmpty && normalizedConfiguredHost == other.normalizedConfiguredHost { + return true + } + if !normalizedHostname.isEmpty && normalizedHostname == other.normalizedHostname { + return true + } + return !addressKeys.isDisjoint(with: other.addressKeys) + } + + mutating func setConfiguredSSHTarget(_ target: String) { + configuredSSHTarget = target + appendConfiguredTargetAddress() + } + + mutating func setAddressValues(_ values: [String], source: DeviceAddressSource = .bonjour) { + addresses = DeviceEndpointPolicy.uniqueAddresses(values.compactMap { DeviceNetworkAddress(value: $0, source: source) }) + appendConfiguredTargetAddress() + } + + mutating func mergeAddresses(_ newAddresses: [DeviceNetworkAddress]) { + addresses = DeviceEndpointPolicy.uniqueAddresses(addresses + newAddresses) + } + + private mutating func appendConfiguredTargetAddress() { + addresses.removeAll { $0.source == .configured } + guard let address = DeviceNetworkAddress(value: configuredSSHTarget, source: .configured) else { + addresses = DeviceEndpointPolicy.uniqueAddresses(addresses) + return + } + addresses = DeviceEndpointPolicy.uniqueAddresses(addresses + [address]) + } + + static func make( + configuredSSHTarget: String, + discoveredDevice: DiscoveredDevice?, + existing: DeviceNetworkIdentity? = nil + ) -> DeviceNetworkIdentity { + var identity = DeviceNetworkIdentity( + configuredSSHTarget: configuredSSHTarget, + hostname: discoveredDevice?.hostname ?? existing?.hostname, + bonjourName: discoveredDevice?.name ?? existing?.bonjourName, + bonjourFullname: discoveredDevice?.fullname ?? existing?.bonjourFullname, + addresses: existing?.addresses ?? [] + ) + if let discoveredDevice { + identity.mergeAddresses(discoveredDevice.networkAddresses) + } + return identity + } + + private static func normalizedOptional(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift index 4068315c..0df9eb24 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift @@ -151,11 +151,7 @@ struct DeviceProfile: Codable, Equatable, Identifiable { var id: ID var displayName: String - var host: String - var bonjourName: String? - var bonjourFullname: String? - var hostname: String? - var addresses: [String] + var network: DeviceNetworkIdentity var syap: String? var model: String? var osName: String? @@ -173,6 +169,43 @@ struct DeviceProfile: Codable, Equatable, Identifiable { var settings: DeviceProfileSettings var passwordState: DevicePasswordState + var host: String { + get { network.configuredSSHTarget } + set { network.setConfiguredSSHTarget(newValue) } + } + + var bonjourName: String? { + get { network.bonjourName } + set { network.bonjourName = newValue } + } + + var bonjourFullname: String? { + get { network.bonjourFullname } + set { network.bonjourFullname = newValue } + } + + var hostname: String? { + get { network.hostname } + set { network.hostname = newValue } + } + + var addresses: [String] { + get { network.addressValues } + set { network.setAddressValues(newValue) } + } + + var connectionTarget: String { + network.preferredSetupTarget + } + + var displayTarget: String { + network.displayTarget + } + + var addressSummary: String { + network.addressSummary + } + var title: String { let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedName.isEmpty { @@ -184,7 +217,7 @@ struct DeviceProfile: Codable, Equatable, Identifiable { if let model = model?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { return model } - return normalizedHost.isEmpty ? "Time Capsule" : normalizedHost + return displayTarget.isEmpty ? "Time Capsule" : displayTarget } var normalizedHost: String { @@ -203,22 +236,11 @@ struct DeviceProfile: Codable, Equatable, Identifiable { } 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() + DeviceEndpointPolicy.normalizedHostKey(host) } 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 + left.network.matches(right.network) } static func make( @@ -234,11 +256,11 @@ struct DeviceProfile: Codable, Equatable, Identifiable { 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 ?? [], + network: DeviceNetworkIdentity.make( + configuredSSHTarget: configuredDevice.host, + discoveredDevice: discoveredDevice, + existing: existing?.network + ), syap: configuredDevice.syap ?? existing?.syap, model: configuredDevice.model ?? existing?.model, osName: compatibility?.osName ?? existing?.osName, @@ -258,13 +280,102 @@ struct DeviceProfile: Codable, Equatable, Identifiable { ) } - private static func normalizedOptional(_ value: String?) -> String? { - guard let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - !normalized.isEmpty else { - return nil - } - return normalized + init( + id: ID, + displayName: String, + network: DeviceNetworkIdentity, + syap: String?, + model: String?, + osName: String?, + osRelease: String?, + arch: String?, + elfEndianness: String?, + payloadFamily: String?, + deviceGeneration: String?, + configPath: String, + keychainAccount: String, + createdAt: Date, + updatedAt: Date, + lastCheckup: DeviceCheckupSnapshot?, + lastDeploy: DeviceDeploySnapshot?, + settings: DeviceProfileSettings, + passwordState: DevicePasswordState + ) { + self.id = id + self.displayName = displayName + self.network = network + self.syap = syap + self.model = model + self.osName = osName + self.osRelease = osRelease + self.arch = arch + self.elfEndianness = elfEndianness + self.payloadFamily = payloadFamily + self.deviceGeneration = deviceGeneration + self.configPath = configPath + self.keychainAccount = keychainAccount + self.createdAt = createdAt + self.updatedAt = updatedAt + self.lastCheckup = lastCheckup + self.lastDeploy = lastDeploy + self.settings = settings + self.passwordState = passwordState } + + init( + id: ID, + displayName: String, + host: String, + bonjourName: String?, + bonjourFullname: String?, + hostname: String?, + addresses: [String], + syap: String?, + model: String?, + osName: String?, + osRelease: String?, + arch: String?, + elfEndianness: String?, + payloadFamily: String?, + deviceGeneration: String?, + configPath: String, + keychainAccount: String, + createdAt: Date, + updatedAt: Date, + lastCheckup: DeviceCheckupSnapshot?, + lastDeploy: DeviceDeploySnapshot?, + settings: DeviceProfileSettings, + passwordState: DevicePasswordState + ) { + self.init( + id: id, + displayName: displayName, + network: DeviceNetworkIdentity( + configuredSSHTarget: host, + hostname: hostname, + bonjourName: bonjourName, + bonjourFullname: bonjourFullname, + addresses: addresses.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + ), + syap: syap, + model: model, + osName: osName, + osRelease: osRelease, + arch: arch, + elfEndianness: elfEndianness, + payloadFamily: payloadFamily, + deviceGeneration: deviceGeneration, + configPath: configPath, + keychainAccount: keychainAccount, + createdAt: createdAt, + updatedAt: updatedAt, + lastCheckup: lastCheckup, + lastDeploy: lastDeploy, + settings: settings, + passwordState: passwordState + ) + } + } extension DiscoveredDevice { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift index d8cd4f84..493ea88c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift @@ -196,16 +196,19 @@ final class DeviceRegistryStore: ObservableObject { } 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 } + let identity = DeviceNetworkIdentity(configuredSSHTarget: host, bonjourFullname: bonjourFullname) + return profiles.first { $0.network.matches(identity) } + } + + func matchingProfile(for device: DiscoveredDevice) -> DeviceProfile? { + let identity = DeviceNetworkIdentity( + configuredSSHTarget: device.connectionTarget, + hostname: device.hostname, + bonjourName: device.name, + bonjourFullname: device.fullname, + addresses: device.networkAddresses + ) + return profiles.first { $0.network.matches(identity) } } private func applyBackgroundMutation(_ mutate: () async throws -> [DeviceProfile]?) async { @@ -314,7 +317,7 @@ private actor DeviceRegistryRepository { existingProfileID: DeviceProfile.ID? = nil ) -> DeviceProfile { let existing = existingProfileID.flatMap { id in profiles.first { $0.id == id } } - ?? matchingProfile(host: configuredDevice.host, bonjourFullname: discoveredDevice?.fullname) + ?? matchingProfile(configuredHost: configuredDevice.host, discoveredDevice: discoveredDevice) var profile = DeviceProfile.make( id: preferredID, configuredDevice: configuredDevice, @@ -456,16 +459,13 @@ private actor DeviceRegistryRepository { } private func matchingProfile(host: String, bonjourFullname: String?) -> DeviceProfile? { - let normalizedFullname = normalizedBonjourFullname(bonjourFullname) - if let normalizedFullname, - let profile = profiles.first(where: { normalizedBonjourFullname($0.bonjourFullname) == normalizedFullname }) { - return profile - } - let normalizedHost = DeviceProfile.normalizedHost(host) - guard !normalizedHost.isEmpty else { - return nil - } - return profiles.first { $0.normalizedHost == normalizedHost } + let identity = DeviceNetworkIdentity(configuredSSHTarget: host, bonjourFullname: bonjourFullname) + return profiles.first { $0.network.matches(identity) } + } + + private func matchingProfile(configuredHost: String, discoveredDevice: DiscoveredDevice?) -> DeviceProfile? { + let identity = DeviceNetworkIdentity.make(configuredSSHTarget: configuredHost, discoveredDevice: discoveredDevice) + return profiles.first { $0.network.matches(identity) } } private func duplicateConflict(for profile: DeviceProfile, excluding profileID: DeviceProfile.ID) -> DeviceRegistryError? { @@ -485,10 +485,40 @@ private actor DeviceRegistryRepository { let conflicting = profiles.first(where: { $0.id != profileID && $0.normalizedHost == normalizedHost }) { return .duplicateProfile( field: "host", - value: normalizedHost, + value: DeviceEndpointPolicy.hostComponent(profile.host) ?? normalizedHost, conflictingProfileID: conflicting.id ) } + let normalizedHostname = profile.network.normalizedHostname + if !normalizedHostname.isEmpty, + let conflicting = profiles.first(where: { + $0.id != profileID && $0.network.normalizedHostname == normalizedHostname + }) { + return .duplicateProfile( + field: "hostname", + value: normalizedHostname, + conflictingProfileID: conflicting.id + ) + } + if let conflict = addressConflict(for: profile, excluding: profileID) { + return conflict + } + return nil + } + + private func addressConflict(for profile: DeviceProfile, excluding profileID: DeviceProfile.ID) -> DeviceRegistryError? { + let keys = profile.network.addressKeys + guard !keys.isEmpty else { + return nil + } + for existing in profiles where existing.id != profileID { + let overlap = keys.intersection(existing.network.addressKeys) + guard let key = overlap.first else { + continue + } + let value = profile.network.addresses.first { $0.identityKey == key }?.value ?? key + return .duplicateProfile(field: "address", value: value, conflictingProfileID: existing.id) + } return nil } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 68e6d2c0..f5817e85 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -16,7 +16,7 @@ "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.host_or_ip" = "Hostname or IP address"; "add_device.password" = "Time Capsule password"; "add_device.progress.configuring.message" = "Verifying access and preparing this Time Capsule. This can take a few seconds..."; "add_device.progress.configuring.title" = "Connecting to Time Capsule"; @@ -27,6 +27,7 @@ "add_device.reset" = "Reset"; "add_device.save_device" = "Save Device"; "add_device.saved" = "Saved %@"; +"add_device.setup_target" = "Setup target: %@"; "add_device.state.auth_failed" = "Password Rejected"; "add_device.state.configuring" = "Configuring"; "add_device.state.discovering" = "Discovering"; @@ -190,7 +191,9 @@ "dashboard.health.time_machine" = "Time Machine"; "dashboard.health.unchecked" = "Run Checkup to inspect this area."; "dashboard.overview.generation" = "Generation"; -"dashboard.overview.host" = "Host"; +"dashboard.overview.addresses" = "Addresses"; +"dashboard.overview.connection_target" = "Connection Target"; +"dashboard.overview.host" = "Connection Target"; "dashboard.overview.last_checkup" = "Last Checkup"; "dashboard.overview.last_install" = "Last Install"; "dashboard.overview.model" = "Model"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift index 9ade1e7a..caed642d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift @@ -175,9 +175,12 @@ private struct DeviceCandidateRow: View { .foregroundStyle(selected ? Color.accentColor : Color.secondary) VStack(alignment: .leading) { Text(device.name) - Text([device.host, device.hostname].filter { !$0.isEmpty }.joined(separator: " ")) + Text([device.hostname, device.addressSummary].filter { !$0.isEmpty }.joined(separator: " ")) .font(.caption) .foregroundStyle(.secondary) + Text(L10n.format("add_device.setup_target", device.connectionTarget)) + .font(.caption2) + .foregroundStyle(.tertiary) } Spacer() if !device.discoveryModelText.isEmpty { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift index 33cdf155..fd662fff 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift @@ -70,7 +70,7 @@ private struct DashboardHeaderView: View { VStack(alignment: .leading, spacing: 3) { Text(presentation.title) .font(.title2.weight(.semibold)) - Text(presentation.host) + Text(presentation.connectionTarget) .font(.callout) .foregroundStyle(.secondary) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift index f416d1d6..97235b6e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift @@ -43,7 +43,7 @@ struct DeviceListOverviewView: View { VStack(alignment: .leading) { Text(profile.title) .font(.body.weight(.medium)) - Text(profile.host) + Text(profile.addressSummary.isEmpty ? profile.displayTarget : profile.addressSummary) .font(.caption) .foregroundStyle(.secondary) } @@ -155,7 +155,7 @@ private struct OverviewDiscoveredDeviceRow: View { Text(device.name) .font(.body.weight(.medium)) HStack(spacing: 6) { - Text(device.host) + Text(device.addressSummary.isEmpty ? device.connectionTarget : device.addressSummary) if !device.discoveryModelText.isEmpty { Text(device.discoveryModelText) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift index a3622c9f..f275c86d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift @@ -12,7 +12,7 @@ struct DeviceSidebarRow: View { Text(profile.title) .lineLimit(1) HStack(spacing: 4) { - Text(profile.host) + Text(profile.displayTarget) .lineLimit(1) if let lastSeenText { Text("- \(lastSeenText)") diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift index ee284fe5..60ffaa4b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift @@ -252,8 +252,9 @@ final class AddDeviceFlowStore: ObservableObject { return } - let targetHost = selectedDevice?.host ?? trimmedHost - let existing = registry.matchingProfile(host: targetHost, bonjourFullname: selectedDevice?.fullname) + let targetHost = selectedDevice?.connectionTarget ?? trimmedHost + let existing = selectedDevice.map { registry.matchingProfile(for: $0) } + ?? registry.matchingProfile(host: targetHost, bonjourFullname: nil) let profileID = existing?.id ?? UUID().uuidString.lowercased() let configureSettings = existing?.settings ?? defaultDeviceSettings @@ -310,8 +311,8 @@ final class AddDeviceFlowStore: ObservableObject { func select(_ device: DiscoveredDevice) { entryMode = .discover selectedDeviceID = device.id - manualHost = device.host - if let existing = registry.matchingProfile(host: device.host, bonjourFullname: device.fullname) { + manualHost = device.connectionTarget + if let existing = registry.matchingProfile(for: device) { savedProfile = existing state = .saved error = nil @@ -474,7 +475,7 @@ final class AddDeviceFlowStore: ObservableObject { DiscoveredDevice(payload: device, index: index) } selectedDeviceID = devices.count == 1 ? devices[0].id : nil - manualHost = devices.count == 1 ? devices[0].host : "" + manualHost = devices.count == 1 ? devices[0].connectionTarget : "" state = devices.isEmpty ? .discoveryEmpty : .discoveryReady error = nil activeOperation = nil diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift index 409cddf9..ed3f7626 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift @@ -52,7 +52,8 @@ enum DashboardSecondaryAction: String, CaseIterable, Equatable, Hashable, Identi struct DeviceDashboardHeaderPresentation: Equatable { let title: String - let host: String + let connectionTarget: String + let addressSummary: String let status: DeviceDisplayStatus let lastChecked: String let rows: [PresentationRow] @@ -60,12 +61,15 @@ struct DeviceDashboardHeaderPresentation: Equatable { init(summary: DeviceDashboardSummary) { let profile = summary.profile self.title = profile.title - self.host = profile.host + self.connectionTarget = profile.displayTarget + self.addressSummary = profile.addressSummary self.status = summary.displayStatus self.lastChecked = profile.lastCheckup .map { Self.formattedDate($0.checkedAt) } ?? L10n.string("value.never") self.rows = [ + PresentationRow(label: L10n.string("dashboard.overview.connection_target"), value: profile.connectionTarget), + PresentationRow(label: L10n.string("dashboard.overview.addresses"), value: profile.addressSummary.isEmpty ? L10n.string("value.unknown") : profile.addressSummary), PresentationRow(label: L10n.string("dashboard.overview.model"), value: profile.model ?? L10n.string("value.unknown")), PresentationRow(label: L10n.string("dashboard.overview.generation"), value: profile.deviceGeneration ?? L10n.string("value.unknown")), PresentationRow(label: L10n.string("dashboard.overview.payload"), value: profile.payloadFamily ?? L10n.string("value.unknown")), diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift index 07ff7039..19be6891 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryMonitorStore.swift @@ -122,7 +122,7 @@ final class DeviceDiscoveryMonitorStore: ObservableObject { } func matchingProfile(for device: DiscoveredDevice) -> DeviceProfile? { - registry.matchingProfile(host: device.host, bonjourFullname: device.fullname) + registry.matchingProfile(for: device) } func lastSeenText(for profile: DeviceProfile) -> String? { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift index 22221b5d..1e4ed340 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -152,6 +152,66 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(fixture.store.devices[1].addresses, ["169.254.44.9", "10.0.0.2"]) } + func testDiscoverPreservesDualStackAddressesAndUsesRegularIPv4SetupTarget() async throws { + let record = testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["169.254.44.9", "10.0.0.2"], + ipv6: ["fd00::2"], + fullname: "Office Capsule._airport._tcp.local." + ) + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]) + ]) + + fixture.store.runDiscover() + + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + let device = try XCTUnwrap(fixture.store.devices.first) + XCTAssertEqual(device.addresses, ["169.254.44.9", "10.0.0.2", "fd00::2"]) + XCTAssertEqual(device.connectionTarget, "10.0.0.2") + XCTAssertEqual(device.addressSummary, "IPv4 10.0.0.2 IPv6 fd00::2") + XCTAssertEqual(fixture.store.hostFieldText, "10.0.0.2") + } + + func testIPv6OnlyDiscoveryConfiguresAndSavesNetworkIdentity() async throws { + let record = testDeviceRecord( + name: "IPv6 Capsule", + hostname: "ipv6-capsule.local.", + ipv4: [], + ipv6: ["fd00::2"], + fullname: "IPv6 Capsule._airport._tcp.local." + ) + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "root@fd00::2")) + ]) + ]) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + let device = try XCTUnwrap(fixture.store.devices.first) + XCTAssertEqual(device.connectionTarget, "fd00::2") + XCTAssertEqual(fixture.store.hostFieldText, "fd00::2") + + fixture.store.select(device) + fixture.store.password = "secret" + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + let profile = try XCTUnwrap(fixture.store.savedProfile) + XCTAssertEqual(profile.connectionTarget, "fd00::2") + XCTAssertEqual(profile.addresses, ["fd00::2"]) + XCTAssertEqual(profile.addressSummary, "IPv6 fd00::2") + XCTAssertNotNil(fixture.runner.calls[1].params["selected_record"]) + XCTAssertNil(fixture.runner.calls[1].params["host"]) + } + func testMalformedDiscoverPayloadFailsContract() async throws { let fixture = try await makeStore(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift index 3506404b..39b48331 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift @@ -25,11 +25,39 @@ final class DeviceProfileTests: XCTestCase { XCTAssertEqual(profile.title, "Time Capsule") } - func testDuplicateMatchingUsesBonjourFullnameAndNormalizedHostOnly() { + func testNetworkIdentityKeepsMultipleAddressesAndPrefersRegularIPv4() { + let profile = makeProfile( + host: "root@10.0.0.2", + hostname: "office-capsule.local.", + addresses: ["169.254.44.9", "10.0.0.2", "fd00::2"] + ) + + XCTAssertEqual(profile.addresses, ["169.254.44.9", "10.0.0.2", "fd00::2"]) + XCTAssertEqual(profile.connectionTarget, "10.0.0.2") + XCTAssertEqual(profile.displayTarget, "office-capsule.local") + XCTAssertEqual(profile.addressSummary, "IPv4 10.0.0.2 IPv6 fd00::2") + } + + func testNetworkIdentitySupportsIPv6OnlyProfiles() { + let profile = makeProfile( + host: "root@fd00::2", + bonjourName: nil, + hostname: nil, + addresses: ["fd00::2"] + ) + + XCTAssertEqual(profile.connectionTarget, "fd00::2") + XCTAssertEqual(profile.displayTarget, "fd00::2") + XCTAssertEqual(profile.addressSummary, "IPv6 fd00::2") + } + + func testDuplicateMatchingUsesBonjourHostHostnameAndAddressIdentityButNotWeakMetadata() { let first = makeProfile( id: "one", host: " TCAPSULE.LOCAL. ", bonjourFullname: "Office Capsule._airport._tcp.local.", + hostname: "office-capsule.local.", + addresses: ["10.0.0.2", "169.254.44.9"], syap: "119", model: "Time Capsule" ) @@ -40,11 +68,15 @@ final class DeviceProfileTests: XCTestCase { ) 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") + let sameHostname = makeProfile(id: "six", host: "10.0.0.10", hostname: "office-capsule.local.") + let sameAddress = makeProfile(id: "seven", host: "10.0.0.11", addresses: ["169.254.44.9"]) + let weakMetadataOnly = makeProfile(id: "four", host: "10.0.0.12", syap: "119", model: "Time Capsule") XCTAssertTrue(DeviceProfile.matches(first, sameFullname)) XCTAssertTrue(DeviceProfile.matches(first, sameHost)) XCTAssertTrue(DeviceProfile.matches(first, sameHostWithRootUser)) + XCTAssertTrue(DeviceProfile.matches(first, sameHostname)) + XCTAssertTrue(DeviceProfile.matches(first, sameAddress)) XCTAssertFalse(DeviceProfile.matches(first, weakMetadataOnly)) } @@ -138,6 +170,8 @@ final class DeviceProfileTests: XCTestCase { host: String = "10.0.0.2", bonjourName: String? = nil, bonjourFullname: String? = nil, + hostname: String? = nil, + addresses: [String] = [], syap: String? = nil, model: String? = nil, osRelease: String? = nil, @@ -151,8 +185,8 @@ final class DeviceProfileTests: XCTestCase { host: host, bonjourName: bonjourName, bonjourFullname: bonjourFullname, - hostname: nil, - addresses: [], + hostname: hostname, + addresses: addresses, syap: syap, model: model, osName: nil, diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift index 1b01c882..cc3de5b5 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift @@ -94,6 +94,19 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(fullnameDuplicate.id, first.id) XCTAssertEqual(store.profiles.count, 1) + let addressDuplicate = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "other.local."), + discoveredDevice: try discovered(record: testDeviceRecord( + hostname: "other.local.", + ipv4: ["10.0.0.2"], + fullname: "Other._airport._tcp.local." + )), + passwordState: .available, + preferredID: "device-address" + ) + XCTAssertEqual(addressDuplicate.id, first.id) + XCTAssertEqual(store.profiles.count, 1) + _ = try await store.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.10", syap: "119", model: "Updated Model"), discoveredDevice: nil, diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index d8b0c58d..211e8c7f 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -117,6 +117,16 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertNil(params["any_protocol"]) } + func testConfigureParamsDefaultBareIPv6ManualHostToRootUser() { + let params = OperationParams.configure( + host: " fd00::2 ", + password: "pw", + debugLogging: false + ) + + XCTAssertEqual(params["host"], .string("root@fd00::2")) + } + func testPendingConfirmationBuildsFromBackendEvent() throws { let event = BackendEvent( type: "error", diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift index d0b09195..664113a3 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift @@ -24,6 +24,19 @@ final class SMBAddressPolicyTests: XCTestCase { XCTAssertEqual(SMBAddressPolicy.url(for: profile)?.absoluteString, "smb://10.0.0.2") } + func testFallsBackToAddressInventoryBeforeConfiguredHostAndFormatsIPv6URLs() { + let profile = makeProfile( + host: "root@capsule.local", + bonjourName: nil, + bonjourFullname: nil, + hostname: nil, + addresses: ["fd00::2"] + ) + + XCTAssertEqual(SMBAddressPolicy.preferredHost(for: profile), "fd00::2") + XCTAssertEqual(SMBAddressPolicy.url(for: profile, account: "James Chang")?.absoluteString, "smb://James%20Chang@[fd00::2]") + } + func testTrimsURLPathAndTrailingDotFromHostCandidates() { let profile = makeProfile( host: "smb://office-capsule.local./Data", @@ -61,7 +74,8 @@ final class SMBAddressPolicyTests: XCTestCase { host: String, bonjourName: String? = "Office Capsule", bonjourFullname: String? = "Office Capsule._airport._tcp.local.", - hostname: String? + hostname: String?, + addresses: [String] = [] ) -> DeviceProfile { DeviceProfile( id: "device-one", @@ -70,7 +84,7 @@ final class SMBAddressPolicyTests: XCTestCase { bonjourName: bonjourName, bonjourFullname: bonjourFullname, hostname: hostname, - addresses: [], + addresses: addresses, syap: "119", model: "TimeCapsule6,116", osName: nil, diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift index 2974dc4b..5d1004c5 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -172,6 +172,7 @@ func testDeviceRecord( name: String = "Office Capsule", hostname: String = "office-capsule.local.", ipv4: [String] = ["10.0.0.2"], + ipv6: [String] = [], syap: String = "119", model: String = "Time Capsule", fullname: String = "Office Capsule._airport._tcp.local.", @@ -184,7 +185,7 @@ func testDeviceRecord( "service_type": .string(serviceType), "port": .number(5009), "ipv4": .array(ipv4.map(JSONValue.string)), - "ipv6": .array([]), + "ipv6": .array(ipv6.map(JSONValue.string)), "services": .array(services.map(JSONValue.string)), "properties": .object([ "syAP": .string(syap), @@ -202,19 +203,25 @@ func testDiscoveredDevice( addresses: [String]? = nil, ipv4: [String]? = nil, ipv6: [String] = [], - preferredIPv4: String? = "10.0.0.2", + preferredIPv4: String? = nil, + sshHost: String? = nil, linkLocalOnly: Bool = false, syap: String? = "119", model: String? = "Time Capsule", fullname: String = "Office Capsule._airport._tcp.local.", selectedRecord: JSONValue? = nil ) -> JSONValue { - let resolvedIPv4 = ipv4 ?? [host] - let resolvedAddresses = addresses ?? (resolvedIPv4 + ipv6) + let hostIsIPv6 = host.contains(":") + let resolvedIPv4 = ipv4 ?? (hostIsIPv6 ? [] : [host]) + let resolvedIPv6 = ipv6.isEmpty && hostIsIPv6 ? [host] : ipv6 + let resolvedPreferredIPv4 = preferredIPv4 ?? resolvedIPv4.first { !$0.hasPrefix("169.254.") } + let resolvedAddresses = addresses ?? (resolvedIPv4 + resolvedIPv6) + let resolvedSSHHost = sshHost ?? ((resolvedPreferredIPv4 != nil || !resolvedIPv6.isEmpty) ? "root@\(host)" : nil) let record = selectedRecord ?? testDeviceRecord( name: name, hostname: hostname, ipv4: resolvedIPv4, + ipv6: resolvedIPv6, syap: syap ?? "", model: model ?? "", fullname: fullname @@ -223,12 +230,12 @@ func testDiscoveredDevice( "id": .string(id), "name": .string(name), "host": .string(host), - "ssh_host": preferredIPv4 == nil ? .null : .string("root@\(host)"), + "ssh_host": resolvedSSHHost.map(JSONValue.string) ?? .null, "hostname": .string(hostname), "addresses": .array(resolvedAddresses.map(JSONValue.string)), "ipv4": .array(resolvedIPv4.map(JSONValue.string)), - "ipv6": .array(ipv6.map(JSONValue.string)), - "preferred_ipv4": preferredIPv4.map(JSONValue.string) ?? .null, + "ipv6": .array(resolvedIPv6.map(JSONValue.string)), + "preferred_ipv4": resolvedPreferredIPv4.map(JSONValue.string) ?? .null, "link_local_only": .bool(linkLocalOnly), "syap": syap.map(JSONValue.string) ?? .null, "model": model.map(JSONValue.string) ?? .null, @@ -247,22 +254,21 @@ func testDiscoverPayload(records: [JSONValue], devices: [JSONValue]? = nil) -> J 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 - } + let ipv4 = testStringArray(record, for: "ipv4") + let ipv6 = testStringArray(record, for: "ipv6") + let preferredIPv4 = ipv4.first { !$0.hasPrefix("169.254.") } + let host = preferredIPv4 ?? ipv6.first ?? hostname + let sshHost = preferredIPv4 != nil || !ipv6.isEmpty ? "root@\(host)" : nil return testDiscoveredDevice( id: "bonjour:\(fullname.lowercased())", name: name, host: host, hostname: hostname, + addresses: ipv4 + ipv6, + ipv4: ipv4, + ipv6: ipv6, + preferredIPv4: preferredIPv4, + sshHost: sshHost, fullname: fullname, selectedRecord: record ) @@ -282,6 +288,19 @@ func testDiscoverPayload(records: [JSONValue], devices: [JSONValue]? = nil) -> J ]) } +func testStringArray(_ value: JSONValue, for key: String) -> [String] { + guard case .object(let object) = value, + case .array(let values)? = object[key] else { + return [] + } + return values.compactMap { item in + guard case .string(let string) = item else { + return nil + } + return string + } +} + func testConfigurePayload( host: String = "10.0.0.2", configPath: String = "/tmp/profile/.env", From 108cf5bafd2ce0a0569831ce7b7a568afde29ae7 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 00:54:58 -0700 Subject: [PATCH 039/129] Add ssh enable confirmation --- .../Backend/PendingConfirmation.swift | 3 +- .../Resources/en.lproj/Localizable.strings | 5 + .../Views/AddDevice/AddDeviceView.swift | 4 + .../Workflows/AddDeviceFlowStore.swift | 29 ++++- .../Workflows/AddDevicePresentation.swift | 1 + .../Workflows/OperationTimeline.swift | 2 + .../AddDeviceFlowStoreTests.swift | 73 ++++++++++++ .../PendingConfirmationTests.swift | 26 +++++ src/timecapsulesmb/app/ops/configure.py | 37 +++++++ src/timecapsulesmb/app/stage_policy.py | 1 + tests/test_app_api.py | 104 ++++++++++++++++++ 11 files changed, 283 insertions(+), 2 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift index ec4f03de..ff4ec1d8 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift @@ -77,7 +77,8 @@ private struct ConfirmationPresentation { } let values = detailObject(details, "presentation_values") switch presentationKey { - case "deploy.activate_now", + case "configure.enable_ssh_reboot", + "deploy.activate_now", "deploy.netbsd4", "deploy.netbsd4_no_wait", "deploy.no_reboot", diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index f5817e85..5105724d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -29,6 +29,7 @@ "add_device.saved" = "Saved %@"; "add_device.setup_target" = "Setup target: %@"; "add_device.state.auth_failed" = "Password Rejected"; +"add_device.state.awaiting_confirmation" = "Waiting for Confirmation"; "add_device.state.configuring" = "Configuring"; "add_device.state.discovering" = "Discovering"; "add_device.state.discovery_empty" = "No Devices Found"; @@ -125,6 +126,9 @@ "confirm.activate.netbsd4.title" = "Activate NetBSD4 Runtime?"; "confirm.backend.message" = "Continue with this operation?"; "confirm.backend.title" = "Confirm Operation?"; +"confirm.configure.enable_ssh_reboot.action" = "Enable SSH and Reboot"; +"confirm.configure.enable_ssh_reboot.message" = "SSH is closed on %@. Enable SSH using AirPort ACP and reboot this Time Capsule?"; +"confirm.configure.enable_ssh_reboot.title" = "Enable SSH And Reboot?"; "confirm.deploy.activate_now.action" = "Deploy and Start SMB"; "confirm.deploy.activate_now.message" = "Deploy TimeCapsuleSMB to this %@ and start SMB without rebooting it?"; "confirm.deploy.activate_now.title" = "Deploy And Start SMB?"; @@ -529,6 +533,7 @@ "timeline.stage.checking_bundled_files" = "Checking Bundled Files"; "timeline.stage.checking_runtime" = "Checking SMB"; "timeline.stage.checking_ssh" = "Checking SSH"; +"timeline.stage.confirming_ssh_enable" = "Confirming SSH Enablement"; "timeline.stage.deleting_old_deployed_files" = "Deleting Old Deployed Files"; "timeline.stage.enabling_ssh" = "Enabling SSH"; "timeline.stage.finding_disk" = "Finding Disk"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift index caed642d..464cd83c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift @@ -144,6 +144,8 @@ struct AddDeviceView: View { return "circle" case .discovering, .configuring, .savingProfile: return "hourglass" + case .awaitingConfirmation: + return "questionmark.circle" case .discoveryReady, .saved: return "checkmark.circle" case .discoveryEmpty: @@ -157,6 +159,8 @@ struct AddDeviceView: View { switch store.state { case .discoveryReady, .saved: return .green + case .awaitingConfirmation: + return .yellow case .authFailed, .unsupported, .failed: return .red default: diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift index 60ffaa4b..3d88f771 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift @@ -9,6 +9,7 @@ enum AddDeviceFlowState: String, CaseIterable, Equatable { case manualEntry case passwordEntry case configuring + case awaitingConfirmation case savingProfile case saved case authFailed @@ -31,6 +32,8 @@ enum AddDeviceFlowState: String, CaseIterable, Equatable { return L10n.string("add_device.state.password_entry") case .configuring: return L10n.string("add_device.state.configuring") + case .awaitingConfirmation: + return L10n.string("add_device.state.awaiting_confirmation") case .savingProfile: return L10n.string("add_device.state.saving_profile") case .saved: @@ -123,7 +126,7 @@ final class AddDeviceFlowStore: ObservableObject { var isRunning: Bool { switch activeLaneKey { case .some(let key): - return coordinator.lane(for: key).backend.isRunning + return coordinator.lane(for: key).isBusy case .none: return false } @@ -445,6 +448,9 @@ final class AddDeviceFlowStore: ObservableObject { } if let stage = OperationStageState(event: event) { currentStage = stage + if event.operation == "configure", state == .awaitingConfirmation { + state = .configuring + } return } if event.type == "error" { @@ -526,6 +532,15 @@ final class AddDeviceFlowStore: ObservableObject { } private func applyError(_ event: BackendEvent) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingConfirmation + return + } + if event.code == "confirmation_cancelled" { + applyConfirmationCancelled() + return + } error = BackendErrorViewModel(event: event) switch event.code { case "auth_failed": @@ -540,6 +555,18 @@ final class AddDeviceFlowStore: ObservableObject { pendingExistingProfileID = nil } + private func applyConfirmationCancelled() { + error = nil + currentStage = nil + savedProfile = nil + activeOperation = nil + activeLaneKey = nil + pendingProfileID = nil + pendingExistingProfileID = nil + pendingDiscoveredDevice = nil + state = .passwordEntry + } + private func failFromResult(_ event: BackendEvent) { error = BackendErrorViewModel( operation: event.operation, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift index fdcfcafd..0a9721cf 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift @@ -24,6 +24,7 @@ struct AddDeviceProgressPresentation: Equatable, BlockingProgressPresenting { .discoveryReady, .manualEntry, .passwordEntry, + .awaitingConfirmation, .saved, .authFailed, .unsupported, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift index 23aa594e..74c6cd06 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift @@ -109,6 +109,8 @@ enum OperationTimelineBuilder { return L10n.string("timeline.stage.finding_time_capsules") case ("configure", "ssh_probe"), ("configure", "ssh_probe_after_acp"): return L10n.string("timeline.stage.checking_ssh") + case ("configure", "confirm_enable_ssh"): + return L10n.string("timeline.stage.confirming_ssh_enable") case ("configure", "acp_enable_ssh"): return L10n.string("timeline.stage.enabling_ssh") case ("configure", "wait_for_ssh_after_acp"): diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift index 1e4ed340..be21e87c 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -12,6 +12,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { .manualEntry, .passwordEntry, .configuring, + .awaitingConfirmation, .savingProfile, .saved, .authFailed, @@ -353,6 +354,78 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls[0].params["debug_logging"], .bool(false)) } + func testConfigureSSHEnableConfirmationCanBeConfirmedAndSavesProfile() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "configure", + code: "confirmation_required", + message: "SSH is closed.", + details: .object([ + "confirmation_id": .string("confirm-ssh"), + "presentation_id": .string("configure.enable_ssh_reboot"), + "presentation_values": .object(["device_name": .string("Office Capsule")]) + ]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "root@10.0.0.2")) + ]) + ]) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .awaitingConfirmation } + XCTAssertFalse(fixture.store.canConfigure) + XCTAssertEqual(fixture.registry.profiles, []) + + fixture.store.coordinator.confirmPending() + + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.runner.calls.count, 2) + XCTAssertEqual(fixture.runner.calls[1].params["confirmation_id"], .string("confirm-ssh")) + XCTAssertEqual(fixture.store.savedProfile?.host, "root@10.0.0.2") + XCTAssertEqual(fixture.registry.profiles.count, 1) + } + + func testConfigureSSHEnableConfirmationCancellationReturnsToPasswordEntry() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "configure", + code: "confirmation_required", + message: "SSH is closed.", + details: .object([ + "confirmation_id": .string("confirm-ssh"), + "presentation_id": .string("configure.enable_ssh_reboot"), + "presentation_values": .object(["device_name": .string("Office Capsule")]) + ]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + try await waitUntilStoreState { fixture.store.state == .awaitingConfirmation } + + fixture.store.coordinator.cancelPendingConfirmation() + + try await waitUntilStoreState { fixture.store.state == .passwordEntry } + XCTAssertNil(fixture.store.error) + XCTAssertNil(fixture.store.savedProfile) + XCTAssertEqual(fixture.store.manualHost, "10.0.0.2") + XCTAssertEqual(fixture.store.password, "secret") + XCTAssertTrue(fixture.store.canConfigure) + XCTAssertEqual(fixture.registry.profiles, []) + } + func testNewManualProfileUsesAppDefaultDeviceSettings() async throws { let fixture = try await makeStore(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index 211e8c7f..362002ff 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -178,6 +178,32 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(confirmation.actionTitle, "Deploy and Reboot") } + func testPendingConfirmationUsesLocalizedConfigureEnableSSHCopy() throws { + let event = BackendEvent( + type: "error", + operation: "configure", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message."), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("configure.enable_ssh_reboot"), + "presentation_values": .object(["device_name": .string("Office Capsule")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Enable SSH And Reboot?") + XCTAssertEqual( + confirmation.message, + "SSH is closed on Office Capsule. Enable SSH using AirPort ACP and reboot this Time Capsule?" + ) + XCTAssertEqual(confirmation.actionTitle, "Enable SSH and Reboot") + } + func testPendingConfirmationUsesLocalizedActivationCopy() throws { let event = BackendEvent( type: "error", diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py index e1fde647..80287f54 100644 --- a/src/timecapsulesmb/app/ops/configure.py +++ b/src/timecapsulesmb/app/ops/configure.py @@ -2,6 +2,7 @@ import uuid +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation 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 @@ -38,6 +39,40 @@ def configure_ssh_target(value: str) -> str: return f"root@{host}" +def selected_record_name(params: dict[str, object]) -> str: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return "" + name = str(selected.get("name") or "").strip() + return name + + +def require_enable_ssh_confirmation(params: dict[str, object], *, host: str) -> None: + device_name = selected_record_name(params) or extract_host(host) + require_confirmation( + params, + build_confirmation( + operation="configure", + params=params, + title="Enable SSH and reboot?", + message=f"SSH is closed on {device_name}. Enable SSH using AirPort ACP and reboot this Time Capsule?", + action_title="Enable SSH and reboot", + risk="reboot", + summary="Enable SSH through AirPort ACP and reboot the Time Capsule", + context={ + "host": host, + "device_name": device_name, + "requires_reboot": True, + }, + presentation_id="configure.enable_ssh_reboot", + presentation_values={ + "device_name": device_name, + "requires_reboot": True, + }, + ), + ) + + def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "configure" sink.stage(operation, "load_existing_config") @@ -91,6 +126,8 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation 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, "confirm_enable_ssh") + require_enable_ssh_confirmation(params, host=host) sink.stage(operation, "acp_enable_ssh") try: enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py index 3bc47caf..2a06ac2e 100644 --- a/src/timecapsulesmb/app/stage_policy.py +++ b/src/timecapsulesmb/app/stage_policy.py @@ -50,6 +50,7 @@ def to_jsonable(self) -> dict[str, object]: ("version-check", "check_version"): StagePolicy(LOCAL_READ, True, "Fetch or read version metadata."), ("configure", "load_existing_config"): StagePolicy(LOCAL_READ, True, "Read the existing .env configuration."), ("configure", "ssh_probe"): StagePolicy(REMOTE_READ, True, "Probe SSH reachability and device compatibility."), + ("configure", "confirm_enable_ssh"): StagePolicy(REBOOT, True, "Confirm SSH enablement and reboot through AirPort ACP."), ("configure", "acp_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."), diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 6c57b0c9..f081e539 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -859,6 +859,108 @@ def test_configure_blank_ata_standby_clears_existing_timer_setting(self) -> None self.assertEqual(values["TC_ATA_IDLE_SECONDS"], "300") self.assertEqual(values["TC_ATA_STANDBY"], "") + def test_configure_requires_confirmation_before_enabling_ssh(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.enable_ssh") as enable_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "secret", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + enable_ssh.assert_not_called() + self.assertFalse(config_path.exists()) + details = self.assert_confirmation( + collector, + "configure.enable_ssh_reboot", + {"device_name": "10.0.0.2", "requires_reboot": True}, + ) + self.assertEqual(details["context"]["host"], "root@10.0.0.2") + self.assertNotIn("secret", json.dumps(collector.events)) + + def test_configure_confirmed_ssh_enable_reprobes_and_writes_env(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + first_collector = CollectingSink() + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "secret", + }, + }, + first_collector.sink, + ) + confirmation_id = self.assert_confirmation(first_collector, "configure.enable_ssh_reboot")["confirmation_id"] + + confirmed_collector = CollectingSink() + with mock.patch( + "timecapsulesmb.app.ops.configure.probe_connection_state", + side_effect=[unreachable_probed_state(), probed_state()], + ) as probe: + with mock.patch("timecapsulesmb.app.ops.configure.enable_ssh") as enable_ssh: + with mock.patch("timecapsulesmb.app.ops.configure.wait_for_ssh_port", return_value=True) as wait_for_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "secret", + "confirmation_id": confirmation_id, + }, + }, + confirmed_collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(probe.call_count, 2) + enable_ssh.assert_called_once() + wait_for_ssh.assert_called_once_with("root@10.0.0.2", timeout_seconds=180) + self.assertEqual(values["TC_HOST"], "root@10.0.0.2") + self.assertNotIn("secret", json.dumps(confirmed_collector.events)) + + def test_configure_enable_ssh_false_fails_without_confirmation(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.enable_ssh") as enable_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "secret", + "enable_ssh": False, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + enable_ssh.assert_not_called() + self.assertFalse(config_path.exists()) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "remote_error") + self.assertNotEqual(error.get("details", {}).get("presentation_id"), "configure.enable_ssh_reboot") + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: @@ -872,6 +974,7 @@ def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: "config": str(config_path), "host": "root@10.0.0.2", "password": "badpw", + "yes": True, }, }, collector.sink, @@ -923,6 +1026,7 @@ def test_configure_rejects_boolean_ssh_wait_timeout(self) -> None: "host": "root@10.0.0.2", "password": "pw", "ssh_wait_timeout": True, + "yes": True, }, }, collector.sink, From 7704d6390a65fd8bd9e45b7734efc4653a14cc7c Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 03:28:18 -0700 Subject: [PATCH 040/129] Add Flash to gui --- .../TimeCapsuleSMBApp/App/AppStore.swift | 32 +- .../TimeCapsuleSMBApp/App/BundleLayout.swift | 127 +++- .../App/DiagnosticsExport.swift | 260 +++++++ .../Backend/BackendPayloads.swift | 278 ++++++++ .../Backend/OperationParams.swift | 64 ++ .../Backend/PendingConfirmation.swift | 6 + .../Resources/en.lproj/Localizable.strings | 57 +- .../Views/Components/ErrorRecoveryView.swift | 16 +- .../Views/Dashboard/CheckupTab.swift | 3 +- .../Views/Dashboard/DeviceDashboardView.swift | 39 +- .../Views/Dashboard/FlashBootHookView.swift | 127 +++- .../Views/Dashboard/InstallTab.swift | 3 +- .../Views/Dashboard/MaintenanceTab.swift | 27 +- .../Views/Dashboard/SettingsTab.swift | 69 +- .../Views/Diagnostics/AppReadinessViews.swift | 59 +- .../Views/Shell/AppSettingsView.swift | 2 +- .../Views/Shell/ContentView.swift | 4 +- .../Workflows/AppReadinessStore.swift | 102 ++- .../Workflows/AppUpdateStore.swift | 18 +- .../Workflows/DeviceDashboardSession.swift | 41 ++ .../Workflows/FlashPresentation.swift | 205 +++++- .../Workflows/FlashWorkflowStore.swift | 543 ++++++++++++++- .../Workflows/MaintenancePresentation.swift | 4 + .../AppReadinessStoreTests.swift | 125 +++- .../AppUpdateStoreTests.swift | 69 ++ .../BundleLayoutTests.swift | 96 ++- .../DiagnosticsExportBuilderTests.swift | 142 ++++ .../FlashWorkflowStoreTests.swift | 462 ++++++++++++- macos/TimeCapsuleSMB/tools/package_app.py | 15 +- src/timecapsulesmb/app/contracts.py | 111 +++ src/timecapsulesmb/app/ops/__init__.py | 3 + src/timecapsulesmb/app/ops/flash.py | 245 +++++++ src/timecapsulesmb/app/ops/readiness.py | 1 + src/timecapsulesmb/app/stage_policy.py | 14 + src/timecapsulesmb/core/config.py | 2 +- src/timecapsulesmb/services/flash.py | 643 ++++++++++++++++++ tests/test_app_api.py | 336 ++++++++- tests/test_config.py | 12 +- tests/test_macos_package_app.py | 19 + 39 files changed, 4205 insertions(+), 176 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/DiagnosticsExport.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppUpdateStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DiagnosticsExportBuilderTests.swift create mode 100644 src/timecapsulesmb/app/ops/flash.py create mode 100644 src/timecapsulesmb/services/flash.py diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift index 74473c8a..fe4fd1cd 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift @@ -175,7 +175,9 @@ final class AppStore: ObservableObject { if previousSettings.telemetryEnabled != settings.telemetryEnabled { syncTelemetryPreference(settings.telemetryEnabled) } - if previousSettings.helperPathOverride != settings.helperPathOverride { + if previousSettings.helperPathOverride != settings.helperPathOverride + || readinessVersionCheck(for: previousSettings) != readinessVersionCheck(for: settings) + { appReadinessStore.start() } } @@ -225,6 +227,29 @@ final class AppStore: ObservableObject { } } + func diagnosticsExportContext(includeBackendEvents: Bool = true) -> DiagnosticsExportContext { + DiagnosticsExportContext( + generatedAt: Date(), + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "development", + appBuild: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "development", + applicationSupportPath: appSettingsStore.settingsURL.deletingLastPathComponent().path, + helperPath: backend.helperPath, + appSettings: appSettingsStore.settings, + readinessState: appReadinessStore.state.kind, + readinessVersionPayload: appReadinessStore.versionCheckPayload, + capabilities: appReadinessStore.capabilities, + validation: appReadinessStore.validation, + runtimeIssues: appReadinessStore.issues, + updateState: appUpdateStore.state, + updatePayload: appUpdateStore.payload, + updateError: appUpdateStore.error, + selectedProfile: selectedProfile, + activeOperations: operationCoordinator.activeOperations, + pendingConfirmation: operationCoordinator.pendingConfirmation, + events: includeBackendEvents ? operationCoordinator.allLanes.flatMap { $0.backend.events } : [] + ) + } + private func effectivePasswordState(for profile: DeviceProfile) -> DevicePasswordState { if profile.passwordState == .invalid { return .invalid @@ -236,9 +261,14 @@ final class AppStore: ObservableObject { if backend.helperPath != settings.helperPathOverride { backend.helperPath = settings.helperPathOverride } + appReadinessStore.applyVersionCheck(readinessVersionCheck(for: settings)) discoveryMonitor.applyAppSettings(settings) } + private func readinessVersionCheck(for settings: AppSettings) -> AppReadinessVersionCheck { + AppReadinessVersionCheck(url: settings.versionCheckURL) + } + private func syncTelemetryPreference(_ enabled: Bool) { let params: [String: JSONValue] = ["enabled": .bool(enabled)] _ = operationCoordinator.run( diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift index 9c8a8212..1d2bb63a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift @@ -17,8 +17,14 @@ public enum BundleRuntimeIssueCode: String, CaseIterable, Equatable, Sendable { case pythonRuntimeMissing case pythonExecutableMissing case distributionRootMissing + case artifactManifestMissing + case artifactManifestInvalid case distributionArtifactsMissing case toolsDirectoryMissing + case applicationSupportUnavailable + case stateDirectoryUnavailable + case unsupportedVersion + case versionMetadataUnavailable case installValidationFailed case helperLaunchFailed case contractDecodeFailed @@ -54,6 +60,7 @@ public struct BundleLayout: Equatable, Sendable { public let resourceURL: URL public let helperURL: URL public let distributionRootURL: URL + public let artifactManifestURL: URL public let toolsBinURL: URL public let pythonRuntimeURL: URL? public let applicationSupportURL: URL @@ -66,6 +73,7 @@ public struct BundleLayout: Equatable, Sendable { resourceURL: URL, helperURL: URL, distributionRootURL: URL? = nil, + artifactManifestURL: URL? = nil, toolsBinURL: URL? = nil, pythonRuntimeURL: URL? = nil, applicationSupportURL: URL, @@ -76,7 +84,10 @@ public struct BundleLayout: Equatable, Sendable { self.executableURL = executableURL self.resourceURL = resourceURL self.helperURL = helperURL - self.distributionRootURL = distributionRootURL ?? resourceURL.appendingPathComponent("Distribution", isDirectory: true) + let resolvedDistributionRoot = distributionRootURL ?? resourceURL.appendingPathComponent("Distribution", isDirectory: true) + self.distributionRootURL = resolvedDistributionRoot + self.artifactManifestURL = artifactManifestURL + ?? resolvedDistributionRoot.appendingPathComponent("artifact-manifest.json") self.toolsBinURL = toolsBinURL ?? resourceURL.appendingPathComponent("Tools/bin", isDirectory: true) self.pythonRuntimeURL = pythonRuntimeURL ?? resourceURL.appendingPathComponent("Python", isDirectory: true) self.applicationSupportURL = applicationSupportURL @@ -156,13 +167,17 @@ public struct BundleLayout: Equatable, Sendable { message: "The bundled TimeCapsuleSMB distribution is missing.", recovery: "Reinstall TimeCapsuleSMB." )) - } else if !isDirectory(distributionRootURL.appendingPathComponent("bin", isDirectory: true), fileManager: fileManager) { - issues.append(BundleRuntimeIssue( - code: .distributionArtifactsMissing, - severity: .error, - message: "The bundled TimeCapsuleSMB payload artifacts are missing.", - recovery: "Reinstall TimeCapsuleSMB." - )) + } else { + let binURL = distributionRootURL.appendingPathComponent("bin", isDirectory: true) + if !isDirectory(binURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .distributionArtifactsMissing, + severity: .error, + message: "The bundled TimeCapsuleSMB payload artifacts are missing.", + recovery: "Reinstall TimeCapsuleSMB." + )) + } + issues.append(contentsOf: artifactManifestIssues(fileManager: fileManager)) } if !isDirectory(toolsBinURL, fileManager: fileManager) { issues.append(BundleRuntimeIssue( @@ -172,11 +187,107 @@ public struct BundleLayout: Equatable, Sendable { recovery: "Some diagnostics may be unavailable until the app bundle is repaired." )) } + if !isWritableDirectory(applicationSupportURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .applicationSupportUnavailable, + severity: .error, + message: "TimeCapsuleSMB cannot write its Application Support directory.", + recovery: "Repair permissions for the TimeCapsuleSMB Application Support folder or reinstall the app." + )) + } + if stateDirectoryURL != applicationSupportURL, + !isWritableDirectory(stateDirectoryURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .stateDirectoryUnavailable, + severity: .error, + message: "TimeCapsuleSMB cannot write its runtime state directory.", + recovery: "Repair permissions for the configured state directory." + )) + } return issues } + private func artifactManifestIssues(fileManager: FileManager) -> [BundleRuntimeIssue] { + guard fileManager.fileExists(atPath: artifactManifestURL.path) else { + return [BundleRuntimeIssue( + code: .artifactManifestMissing, + severity: .error, + message: "The bundled artifact manifest is missing.", + recovery: "Reinstall TimeCapsuleSMB." + )] + } + do { + let data = try Data(contentsOf: artifactManifestURL) + let manifest = try JSONDecoder().decode(ArtifactManifest.self, from: data) + guard !manifest.artifactPaths.contains(where: isUnsafeArtifactPath) else { + return [BundleRuntimeIssue( + code: .artifactManifestInvalid, + severity: .error, + message: "The bundled artifact manifest contains an unsafe artifact path.", + recovery: "Reinstall TimeCapsuleSMB." + )] + } + let missing = manifest.artifactPaths.filter { + !fileManager.fileExists(atPath: distributionRootURL.appendingPathComponent($0).path) + } + guard missing.isEmpty else { + return [BundleRuntimeIssue( + code: .distributionArtifactsMissing, + severity: .error, + message: "The bundled TimeCapsuleSMB distribution is missing \(missing.count) payload artifact(s).", + recovery: "Reinstall TimeCapsuleSMB." + )] + } + return [] + } catch { + return [BundleRuntimeIssue( + code: .artifactManifestInvalid, + severity: .error, + message: "The bundled artifact manifest could not be read.", + recovery: "Reinstall TimeCapsuleSMB." + )] + } + } + + private func isWritableDirectory(_ url: URL, fileManager: FileManager) -> Bool { + do { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } catch { + return false + } + guard isDirectory(url, fileManager: fileManager) else { + return false + } + let probe = url.appendingPathComponent(".timecapsulesmb-write-test-\(UUID().uuidString)") + do { + try Data().write(to: probe) + try? fileManager.removeItem(at: probe) + return true + } catch { + return false + } + } + + private func isUnsafeArtifactPath(_ path: String) -> Bool { + path.isEmpty + || path.hasPrefix("/") + || path.split(separator: "/").contains("..") + } + private func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { var isDirectory: ObjCBool = false return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue } } + +private struct ArtifactManifest: Decodable { + let artifacts: [String: ArtifactRecord] + + var artifactPaths: [String] { + artifacts.values.map(\.path).sorted() + } +} + +private struct ArtifactRecord: Decodable { + let path: String +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/DiagnosticsExport.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/DiagnosticsExport.swift new file mode 100644 index 00000000..97e656d2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/DiagnosticsExport.swift @@ -0,0 +1,260 @@ +import Foundation + +struct DiagnosticsExportContext { + var generatedAt: Date + var appVersion: String + var appBuild: String + var applicationSupportPath: String + var helperPath: String + var appSettings: AppSettings + var readinessState: AppReadinessStateKind + var readinessVersionPayload: VersionCheckPayload? + var capabilities: CapabilitiesPayload? + var validation: InstallValidationPayload? + var runtimeIssues: [BundleRuntimeIssue] + var updateState: AppUpdateState + var updatePayload: VersionCheckPayload? + var updateError: BackendErrorViewModel? + var selectedProfile: DeviceProfile? + var activeOperations: [OperationLaneKey: ActiveOperation] + var pendingConfirmation: PendingConfirmation? + var events: [BackendEvent] +} + +struct DiagnosticsExportBuilder { + var maxEvents = 50 + + func build(context: DiagnosticsExportContext) -> String { + var lines: [String] = [] + lines.append("TimeCapsuleSMB Diagnostics") + lines.append("Generated: \(format(date: context.generatedAt))") + lines.append("") + + appendSection("App", to: &lines) { lines in + append("Version", value: context.appVersion, to: &lines) + append("Build", value: context.appBuild, to: &lines) + append("Application Support", value: context.applicationSupportPath, to: &lines) + append("Helper Override", value: context.helperPath.isEmpty ? "auto" : context.helperPath, to: &lines) + } + + appendSection("Settings", to: &lines) { lines in + append("Telemetry Enabled", value: context.appSettings.telemetryEnabled, to: &lines) + append("Raw Events Default", value: context.appSettings.showRawBackendEventsByDefault, to: &lines) + append("Check Updates On Launch", value: context.appSettings.checkForUpdatesOnLaunch, to: &lines) + append("Version Check URL", value: context.appSettings.versionCheckURL.isEmpty ? "auto" : context.appSettings.versionCheckURL, to: &lines) + append("Time Machine Warnings", value: context.appSettings.timeMachineWarningsEnabled, to: &lines) + append("Default NBNS", value: context.appSettings.defaultDeviceSettings.nbnsEnabled, to: &lines) + append("Default Debug Logging", value: context.appSettings.defaultDeviceSettings.debugLogging, to: &lines) + append("Default Mount Wait", value: context.appSettings.defaultDeviceSettings.mountWaitSeconds, to: &lines) + append("Default ATA Idle", value: context.appSettings.defaultDeviceSettings.ataIdleSeconds, to: &lines) + append("Default ATA Standby", value: context.appSettings.defaultDeviceSettings.ataStandby.map(String.init) ?? "device default", to: &lines) + } + + appendSection("Readiness", to: &lines) { lines in + append("State", value: context.readinessState.title, to: &lines) + if let version = context.readinessVersionPayload { + append("Version Check", value: "\(version.summary) Source: \(version.source)", to: &lines) + } + if let capabilities = context.capabilities { + append("Helper Version", value: "\(capabilities.helperVersion) (\(capabilities.helperVersionCode))", to: &lines) + append("Distribution Root", value: capabilities.distributionRoot, to: &lines) + append("Artifact Manifest SHA256", value: capabilities.artifactManifestSHA256 ?? "missing", to: &lines) + append("Operations", value: capabilities.operations.sorted().joined(separator: ", "), to: &lines) + } + if let validation = context.validation { + append("Validation", value: validation.summary, to: &lines) + append("Validation Counts", value: sortedDescription(validation.counts), to: &lines) + for check in validation.checks { + append("Check \(check.id)", value: "\(check.ok ? "PASS" : "FAIL") - \(check.message)", to: &lines) + } + } + if context.runtimeIssues.isEmpty { + append("Runtime Issues", value: "none", to: &lines) + } else { + for issue in context.runtimeIssues { + append("Runtime Issue", value: "\(issue.severity.rawValue)/\(issue.code.rawValue): \(issue.message) Recovery: \(issue.recovery)", to: &lines) + } + } + } + + appendSection("Updates", to: &lines) { lines in + append("State", value: context.updateState.title, to: &lines) + if let payload = context.updatePayload { + append("Summary", value: payload.summary, to: &lines) + append("Source", value: payload.source, to: &lines) + append("Local Version Code", value: payload.localVersionCode, to: &lines) + append("Current Version", value: payload.currentVersion.map(String.init) ?? "unknown", to: &lines) + append("Minimum Supported Version", value: payload.minSupportedVersion.map(String.init) ?? "unknown", to: &lines) + append("Latest Tag", value: payload.latestTag ?? "unknown", to: &lines) + append("Download URL", value: payload.downloadURL, to: &lines) + } + if let error = context.updateError { + append("Error", value: "\(error.operation) \(error.code): \(error.message)", to: &lines) + } + } + + appendSection("Selected Device", to: &lines) { lines in + if let profile = context.selectedProfile { + append("ID", value: profile.id, to: &lines) + append("Name", value: profile.title, to: &lines) + append("Host", value: profile.displayTarget, to: &lines) + append("Model", value: profile.model ?? "unknown", to: &lines) + append("SYAP", value: profile.syap ?? "unknown", to: &lines) + append("OS", value: [profile.osName, profile.osRelease].compactMap { $0 }.joined(separator: " ").nilIfEmpty ?? "unknown", to: &lines) + append("Arch", value: profile.arch ?? "unknown", to: &lines) + append("Payload Family", value: profile.payloadFamily ?? "unknown", to: &lines) + append("Password State", value: profile.passwordState.title, to: &lines) + append("Last Checkup", value: profile.lastCheckup?.summary ?? "none", to: &lines) + append("Last Deploy", value: profile.lastDeploy?.summary ?? "none", to: &lines) + } else { + append("Selected", value: "none", to: &lines) + } + } + + appendSection("Operations", to: &lines) { lines in + if context.activeOperations.isEmpty { + append("Active", value: "none", to: &lines) + } else { + for key in context.activeOperations.keys.sorted(by: { $0.description < $1.description }) { + guard let operation = context.activeOperations[key] else { continue } + append("Active \(key.description)", value: operation.operation, to: &lines) + } + } + if let confirmation = context.pendingConfirmation { + append("Pending Confirmation", value: "\(confirmation.operation): \(confirmation.title)", to: &lines) + } else { + append("Pending Confirmation", value: "none", to: &lines) + } + } + + appendSection("Backend Events", to: &lines) { lines in + let boundedEvents = context.events.suffix(maxEvents) + if boundedEvents.isEmpty { + append("Events", value: "none", to: &lines) + } else { + for event in boundedEvents { + append("Event", value: eventSummary(event), to: &lines) + } + } + } + + return lines.joined(separator: "\n") + } + + private func appendSection(_ title: String, to lines: inout [String], body: (inout [String]) -> Void) { + lines.append("## \(title)") + body(&lines) + lines.append("") + } + + private func append(_ label: String, value: Bool, to lines: inout [String]) { + append(label, value: value ? "true" : "false", to: &lines) + } + + private func append(_ label: String, value: Int, to lines: inout [String]) { + append(label, value: String(value), to: &lines) + } + + private func append(_ label: String, value: String, to lines: inout [String]) { + lines.append("- \(label): \(redacted(value, key: label))") + } + + private func eventSummary(_ event: BackendEvent) -> String { + var parts = [ + event.type, + event.operation, + event.code, + event.stage, + event.status, + event.message + ].compactMap { $0?.nilIfEmpty } + if let payload = event.payload { + parts.append("payload=\(redacted(payload, key: "payload").compactDisplayText)") + } + if let details = event.details { + parts.append("details=\(redacted(details, key: "details").compactDisplayText)") + } + if let debug = event.debug { + parts.append("debug=\(redacted(debug, key: "debug").compactDisplayText)") + } + return parts.joined(separator: " | ") + } + + private func redacted(_ value: JSONValue, key: String?) -> JSONValue { + if shouldRedact(key: key) { + return .string("") + } + switch value { + case .object(let object): + return .object(object.mapValuesWithKeys { childKey, childValue in + redacted(childValue, key: childKey) + }) + case .array(let values): + return .array(values.map { redacted($0, key: key) }) + default: + return value + } + } + + private func redacted(_ value: String, key: String?) -> String { + shouldRedact(key: key) ? "" : value + } + + private func shouldRedact(key: String?) -> Bool { + guard let key = key?.lowercased() else { + return false + } + return key.contains("password") + || key.contains("token") + || key.contains("secret") + || key.contains("authorization") + || key.contains("api_key") + || key.contains("apikey") + || key.contains("private_key") + || key.contains("privatekey") + || key.contains("credentials") + } + + private func sortedDescription(_ values: [String: Int]) -> String { + values.keys.sorted().map { "\($0)=\(values[$0] ?? 0)" }.joined(separator: ", ") + } + + private func format(date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } +} + +private extension JSONValue { + var compactDisplayText: String { + guard let data = try? JSONEncoder.sortedCompact.encode(self), + let text = String(data: data, encoding: .utf8) + else { + return displayText + } + return text + } +} + +private extension JSONEncoder { + static var sortedCompact: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + } +} + +private extension Dictionary { + func mapValuesWithKeys(_ transform: (Key, Value) -> T) -> [Key: T] { + Dictionary(uniqueKeysWithValues: map { element in + (element.key, transform(element.key, element.value)) + }) + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift index 260536b5..18c32447 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift @@ -698,6 +698,284 @@ struct RepairXattrsPayload: Decodable, Equatable { } } +struct FlashBankPayload: Decodable, Equatable, Identifiable { + let name: String + let device: String + let size: Int + let sha256: String + let backupValid: Bool + let activeCandidate: Bool + let wouldWrite: Bool + let writeDecision: String + let login: JSONValue? + let footer: JSONValue? + let patch: JSONValue? + let patchError: String? + let analysisError: String? + + var id: String { name } + + enum CodingKeys: String, CodingKey { + case name + case device + case size + case sha256 + case backupValid = "backup_valid" + case activeCandidate = "active_candidate" + case wouldWrite = "would_write" + case writeDecision = "write_decision" + case login + case footer + case patch + case patchError = "patch_error" + case analysisError = "analysis_error" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self.device = try container.decodeIfPresent(String.self, forKey: .device) ?? "" + self.size = try container.decodeIfPresent(Int.self, forKey: .size) ?? 0 + self.sha256 = try container.decodeIfPresent(String.self, forKey: .sha256) ?? "" + self.backupValid = try container.decodeIfPresent(Bool.self, forKey: .backupValid) ?? false + self.activeCandidate = try container.decodeIfPresent(Bool.self, forKey: .activeCandidate) ?? false + self.wouldWrite = try container.decodeIfPresent(Bool.self, forKey: .wouldWrite) ?? false + self.writeDecision = try container.decodeIfPresent(String.self, forKey: .writeDecision) ?? "" + self.login = try container.decodeIfPresent(JSONValue.self, forKey: .login) + self.footer = try container.decodeIfPresent(JSONValue.self, forKey: .footer) + self.patch = try container.decodeIfPresent(JSONValue.self, forKey: .patch) + self.patchError = try container.decodeIfPresent(String.self, forKey: .patchError) + self.analysisError = try container.decodeIfPresent(String.self, forKey: .analysisError) + } +} + +struct FlashBackupPayload: Decodable, Equatable { + let schemaVersion: Int + let backupDir: String + let host: String? + let syap: String? + let deviceModel: String? + let osRelease: String? + let activeBank: String? + let banks: [FlashBankPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case backupDir = "backup_dir" + case host + case syap + case deviceModel = "device_model" + case osRelease = "os_release" + case activeBank = "active_bank" + case banks + case counts + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.backupDir = try container.decode(String.self, forKey: .backupDir) + self.host = try container.decodeIfPresent(String.self, forKey: .host) + self.syap = try container.decodeIfPresent(String.self, forKey: .syap) + self.deviceModel = try container.decodeIfPresent(String.self, forKey: .deviceModel) + self.osRelease = try container.decodeIfPresent(String.self, forKey: .osRelease) + self.activeBank = try container.decodeIfPresent(String.self, forKey: .activeBank) + self.banks = try container.decodeIfPresent([FlashBankPayload].self, forKey: .banks) ?? [] + self.counts = try container.decodeIfPresent([String: Int].self, forKey: .counts) ?? [:] + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct FlashAppleFirmwareMatchPayload: Decodable, Equatable { + let matched: Bool + let templateSource: String + let templatePath: String? + let templateProductID: String? + let templateVersion: String? + let templateSHA256: String? + let innerSHA256: String? + let innerSize: Int? + let keyID: String? + let innerModel: Int? + let innerVersion: String? + + enum CodingKeys: String, CodingKey { + case matched + case templateSource = "template_source" + case templatePath = "template_path" + case templateProductID = "template_product_id" + case templateVersion = "template_version" + case templateSHA256 = "template_sha256" + case innerSHA256 = "inner_sha256" + case innerSize = "inner_size" + case keyID = "key_id" + case innerModel = "inner_model" + case innerVersion = "inner_version" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.matched = try container.decodeIfPresent(Bool.self, forKey: .matched) ?? false + self.templateSource = try container.decodeIfPresent(String.self, forKey: .templateSource) ?? "" + self.templatePath = try container.decodeIfPresent(String.self, forKey: .templatePath) + self.templateProductID = try container.decodeIfPresent(String.self, forKey: .templateProductID) + self.templateVersion = try container.decodeIfPresent(String.self, forKey: .templateVersion) + self.templateSHA256 = try container.decodeIfPresent(String.self, forKey: .templateSHA256) + self.innerSHA256 = try container.decodeIfPresent(String.self, forKey: .innerSHA256) + self.innerSize = try container.decodeIfPresent(Int.self, forKey: .innerSize) + self.keyID = try container.decodeIfPresent(String.self, forKey: .keyID) + self.innerModel = try container.decodeIfPresent(Int.self, forKey: .innerModel) + self.innerVersion = try container.decodeIfPresent(String.self, forKey: .innerVersion) + } +} + +struct FlashFirmwarePayload: Decodable, Equatable { + let templateSource: String + let templatePath: String? + let templateProductID: String? + let templateVersion: String? + let templateSHA256: String? + let payloadSHA256: String? + let payloadSize: Int? + let expectedPrefixSHA256: String? + let expectedPrefixSize: Int? + let expectedLoginClassification: String? + let keyID: String? + let innerModel: Int? + let innerVersion: String? + let innerPayloadSize: Int? + + enum CodingKeys: String, CodingKey { + case templateSource = "template_source" + case templatePath = "template_path" + case templateProductID = "template_product_id" + case templateVersion = "template_version" + case templateSHA256 = "template_sha256" + case payloadSHA256 = "payload_sha256" + case payloadSize = "payload_size" + case expectedPrefixSHA256 = "expected_prefix_sha256" + case expectedPrefixSize = "expected_prefix_size" + case expectedLoginClassification = "expected_login_classification" + case keyID = "key_id" + case innerModel = "inner_model" + case innerVersion = "inner_version" + case innerPayloadSize = "inner_payload_size" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.templateSource = try container.decodeIfPresent(String.self, forKey: .templateSource) ?? "" + self.templatePath = try container.decodeIfPresent(String.self, forKey: .templatePath) + self.templateProductID = try container.decodeIfPresent(String.self, forKey: .templateProductID) + self.templateVersion = try container.decodeIfPresent(String.self, forKey: .templateVersion) + self.templateSHA256 = try container.decodeIfPresent(String.self, forKey: .templateSHA256) + self.payloadSHA256 = try container.decodeIfPresent(String.self, forKey: .payloadSHA256) + self.payloadSize = try container.decodeIfPresent(Int.self, forKey: .payloadSize) + self.expectedPrefixSHA256 = try container.decodeIfPresent(String.self, forKey: .expectedPrefixSHA256) + self.expectedPrefixSize = try container.decodeIfPresent(Int.self, forKey: .expectedPrefixSize) + self.expectedLoginClassification = try container.decodeIfPresent(String.self, forKey: .expectedLoginClassification) + self.keyID = try container.decodeIfPresent(String.self, forKey: .keyID) + self.innerModel = try container.decodeIfPresent(Int.self, forKey: .innerModel) + self.innerVersion = try container.decodeIfPresent(String.self, forKey: .innerVersion) + self.innerPayloadSize = try container.decodeIfPresent(Int.self, forKey: .innerPayloadSize) + } +} + +struct FlashPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let backupDir: String + let mode: FlashPlanMode + let writeRequested: Bool + let alreadySatisfied: Bool + let activeBank: String? + let banks: [FlashBankPayload] + let flashPlan: JSONValue? + let appleFirmwareMatch: FlashAppleFirmwareMatchPayload? + let firmwarePayload: FlashFirmwarePayload? + let firmwarePayloadPath: String? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case backupDir = "backup_dir" + case mode + case writeRequested = "write_requested" + case alreadySatisfied = "already_satisfied" + case activeBank = "active_bank" + case banks + case flashPlan = "flash_plan" + case appleFirmwareMatch = "apple_firmware_match" + case firmwarePayload = "firmware_payload" + case firmwarePayloadPath = "firmware_payload_path" + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.backupDir = try container.decode(String.self, forKey: .backupDir) + self.mode = try container.decodeIfPresent(FlashPlanMode.self, forKey: .mode) ?? .patch + self.writeRequested = try container.decodeIfPresent(Bool.self, forKey: .writeRequested) ?? false + self.alreadySatisfied = try container.decodeIfPresent(Bool.self, forKey: .alreadySatisfied) ?? false + self.activeBank = try container.decodeIfPresent(String.self, forKey: .activeBank) + self.banks = try container.decodeIfPresent([FlashBankPayload].self, forKey: .banks) ?? [] + self.flashPlan = try container.decodeIfPresent(JSONValue.self, forKey: .flashPlan) + self.appleFirmwareMatch = try container.decodeIfPresent(FlashAppleFirmwareMatchPayload.self, forKey: .appleFirmwareMatch) + self.firmwarePayload = try container.decodeIfPresent(FlashFirmwarePayload.self, forKey: .firmwarePayload) + self.firmwarePayloadPath = try container.decodeIfPresent(String.self, forKey: .firmwarePayloadPath) + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct FlashWritePayload: Decodable, Equatable { + let schemaVersion: Int + let backupDir: String + let mode: FlashPlanMode + let writeStatus: String + let writeValidated: Bool + let writeOutcome: JSONValue? + let writeResult: JSONValue? + let writeMayHaveModifiedDevice: Bool + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case backupDir = "backup_dir" + case mode + case writeStatus = "write_status" + case writeValidated = "write_validated" + case writeOutcome = "write_outcome" + case writeResult = "write_result" + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.backupDir = try container.decode(String.self, forKey: .backupDir) + self.mode = try container.decodeIfPresent(FlashPlanMode.self, forKey: .mode) ?? .patch + self.writeStatus = try container.decodeIfPresent(String.self, forKey: .writeStatus) ?? "" + self.writeValidated = try container.decodeIfPresent(Bool.self, forKey: .writeValidated) ?? false + self.writeOutcome = try container.decodeIfPresent(JSONValue.self, forKey: .writeOutcome) + self.writeResult = try container.decodeIfPresent(JSONValue.self, forKey: .writeResult) + self.writeMayHaveModifiedDevice = Self.decodeWriteMayHaveModifiedDevice(from: writeOutcome) + self.summary = try container.decode(String.self, forKey: .summary) + } + + private static func decodeWriteMayHaveModifiedDevice(from value: JSONValue?) -> Bool { + guard let value, case .object(let values) = value else { + return false + } + guard case .bool(let mayHaveModified)? = values["write_may_have_modified_device"] else { + return false + } + return mayHaveModified + } +} + struct MaintenanceResultPayload: Decodable, Equatable { let schemaVersion: Int let summary: String diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift index 36e67f0c..10a5dfbc 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift @@ -28,6 +28,15 @@ enum OperationParams { ["timeout": .number(timeout)] } + static func versionCheck(url: String) -> [String: JSONValue] { + var params: [String: JSONValue] = [:] + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedURL.isEmpty { + params["url"] = .string(trimmedURL) + } + return params + } + static func configure( host: String = "", selectedRecord: JSONValue? = nil, @@ -201,6 +210,61 @@ enum OperationParams { repairXattrsParams(path: path, dryRun: false, options: options) } + static func flashBackup(password: String) -> [String: JSONValue] { + withCredentials([ + "action": .string("backup") + ], password: password) + } + + static func flashPlan( + backupDir: String, + mode: FlashPlanMode, + force: Bool = false, + firmwareVersion: String = "", + firmwareTemplate: String = "" + ) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "action": .string("plan"), + "backup_dir": .string(backupDir), + "mode": .string(mode.rawValue), + "force": .bool(force) + ] + let trimmedVersion = firmwareVersion.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedVersion.isEmpty { + params["firmware_version"] = .string(trimmedVersion) + } + let trimmedTemplate = firmwareTemplate.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedTemplate.isEmpty { + params["firmware_template"] = .string(trimmedTemplate) + } + return params + } + + static func flashWrite( + backupDir: String, + mode: FlashPlanMode, + force: Bool = false, + firmwareVersion: String = "", + firmwareTemplate: String = "", + password: String + ) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "action": .string("write"), + "backup_dir": .string(backupDir), + "mode": .string(mode.rawValue), + "force": .bool(force) + ] + let trimmedVersion = firmwareVersion.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedVersion.isEmpty { + params["firmware_version"] = .string(trimmedVersion) + } + let trimmedTemplate = firmwareTemplate.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedTemplate.isEmpty { + params["firmware_template"] = .string(trimmedTemplate) + } + return withCredentials(params, password: password) + } + private static func repairXattrsParams(path: String, dryRun: Bool, options: RepairXattrsOptions) -> [String: JSONValue] { var params: [String: JSONValue] = [ "path": .string(path), diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift index ff4ec1d8..5930fe91 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift @@ -93,6 +93,12 @@ private struct ConfirmationPresentation { return nil } return format(template, path) + case "flash.patch_write", + "flash.restore_write": + guard let host = stringValue(values, "host") else { + return nil + } + return format(template, host) default: return template } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 5105724d..552c4945 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -43,12 +43,11 @@ "add_device.state.unsupported" = "Unsupported"; "add_device.title" = "Add Time Capsule"; "advanced.config" = "Config"; -"advanced.flash_cli_only" = "Flash backup, patch, and restore remain CLI-only in this version."; -"advanced.flash_help" = "Use `.venv/bin/tcapsule flash --help` for firmware operations."; "advanced.helper" = "Helper"; "advanced.profile_id" = "Profile ID"; "app_readiness.state.blocked" = "Blocked"; "app_readiness.state.checking_capabilities" = "Checking helper"; +"app_readiness.state.checking_version" = "Checking version"; "app_readiness.state.degraded" = "Degraded"; "app_readiness.state.idle" = "Idle"; "app_readiness.state.ready" = "Ready"; @@ -59,6 +58,8 @@ "app_readiness.recovery.helper_missing" = "Reinstall TimeCapsuleSMB or choose a valid helper in Diagnostics."; "app_readiness.recovery.install_validation_failed" = "Reinstall TimeCapsuleSMB or open Diagnostics for the failed checks."; "app_readiness.recovery.retry_diagnostics" = "Open Diagnostics and retry app readiness."; +"app_readiness.recovery.update_required" = "Download the latest version from %@."; +"app_readiness.recovery.version_metadata_unavailable" = "Check your network connection or try again later."; "app_settings.blank_uses_device_default" = "Blank"; "app_settings.check_now" = "Check Now"; "app_settings.check_updates_on_launch" = "Check for updates on launch"; @@ -88,6 +89,7 @@ "app_update.state.current" = "TimeCapsuleSMB is up to date."; "app_update.state.failed" = "Update check failed."; "app_update.state.idle" = "Not checked yet."; +"app_update.state.unavailable" = "Version metadata unavailable."; "app_update.state.update_available" = "Update available."; "button.activate" = "Activate"; "button.capabilities" = "Capabilities"; @@ -153,6 +155,12 @@ "confirm.fsck.reboot.action" = "Run fsck"; "confirm.fsck.reboot.message" = "Run fsck on the selected HFS volume and reboot the device?"; "confirm.fsck.reboot.title" = "Run Disk Repair And Reboot?"; +"confirm.flash.patch_write.action" = "Write Firmware"; +"confirm.flash.patch_write.message" = "Patch the primary firmware bank boot hook on %@? Manual power cycle is required after a successful write."; +"confirm.flash.patch_write.title" = "Patch Firmware Boot Hook?"; +"confirm.flash.restore_write.action" = "Write Firmware"; +"confirm.flash.restore_write.message" = "Restore Apple stock firmware to the active firmware bank on %@?"; +"confirm.flash.restore_write.title" = "Restore Apple Firmware?"; "confirm.repair_xattrs.action" = "Repair xattrs"; "confirm.repair_xattrs.message" = "Repair known-safe macOS metadata issues under %@?"; "confirm.repair_xattrs.title" = "Repair Extended Attributes?"; @@ -278,9 +286,13 @@ "install.warning.awaiting_confirmation" = "The backend is waiting for explicit confirmation."; "install.warning.plan_stale" = "Regenerate the plan before installing."; "diagnostics.backend_events" = "Backend Events"; +"diagnostics.copied" = "Copied diagnostics."; +"diagnostics.copy" = "Copy Diagnostics"; "diagnostics.distribution" = "Distribution"; "diagnostics.helper" = "Helper"; "diagnostics.runtime_issues" = "Runtime Issues"; +"diagnostics.save" = "Save Diagnostics..."; +"diagnostics.saved" = "Saved diagnostics."; "diagnostics.state" = "State"; "diagnostics.title" = "Diagnostics"; "diagnostics.validation" = "Validation"; @@ -308,6 +320,8 @@ "field.ata_standby" = "ATA standby seconds"; "field.bonjour_timeout" = "Bonjour timeout seconds"; "field.fsck_volume" = "fsck volume, optional"; +"field.firmware_template" = "Firmware template path, optional"; +"field.firmware_version" = "Firmware version, optional"; "field.helper" = "Helper"; "field.host" = "Host"; "field.mount_wait" = "Mount wait seconds"; @@ -315,9 +329,42 @@ "field.repair_xattrs_max_depth" = "Max depth"; "field.repair_xattrs_path" = "Repair xattrs path"; "flash.action.backup_inspect" = "Back Up and Inspect"; -"flash.action.patch_boot_hook" = "Patch Boot Hook"; -"flash.action.restore_firmware" = "Restore Apple Firmware"; +"flash.action.backup_inspect_again" = "Back Up and Inspect Again"; +"flash.action.check_apple" = "Check Apple Firmware"; +"flash.action.choose_template" = "Choose"; +"flash.action.download_apple" = "Validate Apple Restore Firmware"; +"flash.action.plan_patch" = "Plan Patch"; +"flash.action.plan_restore" = "Plan Restore"; +"flash.action.write_patch" = "Write Patch"; +"flash.action.write_restore" = "Write Restore"; +"flash.manual_power_cycle.message" = "Flash write validation completed. Unplug the Time Capsule, wait 10 seconds, then plug it back in to reboot it."; +"flash.manual_power_cycle.title" = "Manual Reboot Required"; +"flash.mode.check_apple" = "Check Apple Firmware"; +"flash.mode.download_only" = "Validate Apple Restore Firmware"; +"flash.mode.patch" = "Patch Boot Hook"; +"flash.mode.restore" = "Restore Apple Firmware"; +"flash.options.apple_firmware" = "Apple Firmware Options"; +"flash.row.active_bank" = "Active Bank"; +"flash.row.apple_match" = "Apple Match"; +"flash.row.apple_payload_sha256" = "Apple Payload SHA-256"; +"flash.row.apple_product" = "Apple Product"; +"flash.row.apple_source" = "Apple Source"; +"flash.row.apple_version" = "Apple Version"; +"flash.row.backup_dir" = "Backup"; +"flash.row.banks" = "Banks"; +"flash.row.firmware_payload_path" = "Firmware Payload"; +"flash.row.firmware_payload_sha256" = "Firmware Payload SHA-256"; +"flash.row.firmware_payload_size" = "Firmware Payload Size"; +"flash.row.firmware_product" = "Firmware Product"; +"flash.row.firmware_source" = "Firmware Source"; +"flash.row.firmware_version" = "Firmware Version"; +"flash.row.mode" = "Mode"; +"flash.row.write_requested" = "Write Requested"; +"flash.row.write_status" = "Write Status"; +"flash.row.write_validated" = "Write Validated"; "flash.title" = "Persistent NetBSD4 Boot Hook"; +"flash.warning.manual_power_cycle" = "Unplug the Time Capsule, wait 10 seconds, then plug it back in."; +"flash.warning.snapshot_stale" = "Firmware was written after this backup. Back up and inspect again before planning another flash action."; "helper.error.cancelled" = "Operation cancelled."; "helper.error.missing_terminal_event" = "Helper exited without a result or error event."; "host_warning.time_machine.message" = "macOS %d.%d.%d has known Time Machine network backup issues. SMB may work, but backup reliability can be affected by the host OS."; @@ -451,6 +498,7 @@ "password.error.required" = "Password is required."; "password.error.unreadable_keychain_item" = "Keychain returned an unreadable password."; "profile_editor.advanced" = "Advanced"; +"profile_editor.advanced.deploy_notice" = "Please do a Deploy to update these settings to your device"; "profile_editor.display_name" = "Display Name"; "profile_editor.error.duplicate_host" = "Another saved Time Capsule already uses this host."; "profile_editor.error.host_required" = "Host is required."; @@ -472,6 +520,7 @@ "profile_editor.title" = "Device Profile"; "readiness.blocked.title" = "TimeCapsuleSMB cannot start"; "readiness.state.checking_capabilities" = "Checking helper"; +"readiness.state.checking_version" = "Checking version"; "readiness.state.resolving_bundle" = "Preparing app runtime"; "readiness.state.validating_install" = "Validating bundled files"; "readiness.warning.default" = "TimeCapsuleSMB is running with warnings."; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift index f4d3caa8..ae2ef8cd 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift @@ -18,6 +18,17 @@ struct ErrorBlock: View { struct ErrorRecoveryView: View { let error: BackendErrorViewModel let onAction: (RecoveryAction) -> Void + let diagnosticsText: (() -> String)? + + init( + error: BackendErrorViewModel, + diagnosticsText: (() -> String)? = nil, + onAction: @escaping (RecoveryAction) -> Void + ) { + self.error = error + self.diagnosticsText = diagnosticsText + self.onAction = onAction + } var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -49,7 +60,10 @@ struct ErrorRecoveryView: View { private func copyDiagnostics() { let pasteboard = NSPasteboard.general pasteboard.clearContents() - pasteboard.setString("\(error.operation) \(error.code): \(error.message)", forType: .string) + pasteboard.setString( + diagnosticsText?() ?? "\(error.operation) \(error.code): \(error.message)", + forType: .string + ) } private func icon(for kind: RecoveryActionKind) -> String { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift index 5b73ae64..91f6a1bc 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift @@ -5,6 +5,7 @@ struct CheckupTab: View { @ObservedObject var session: DeviceDashboardSession let appSettings: AppSettings let showDiagnostics: () -> Void + let diagnosticsText: () -> String var body: some View { let store = session.doctorStore @@ -51,7 +52,7 @@ struct CheckupTab: View { CheckupAdvancedOptionsView(store: store) if let error = store.error { - ErrorRecoveryView(error: error) { action in + ErrorRecoveryView(error: error, diagnosticsText: diagnosticsText) { action in handleRecovery(action: action, error: error) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift index c2cb215c..8b3cd35e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift @@ -27,17 +27,24 @@ struct DeviceDashboardView: View { profile: profile, session: session, appSettings: appStore.appSettingsStore.settings, - showDiagnostics: showDiagnostics + showDiagnostics: showDiagnostics, + diagnosticsText: diagnosticsText ) case .checkup: CheckupTab( profile: profile, session: session, appSettings: appStore.appSettingsStore.settings, - showDiagnostics: showDiagnostics + showDiagnostics: showDiagnostics, + diagnosticsText: diagnosticsText ) case .maintenance: - MaintenanceTab(profile: profile, session: session, showDiagnostics: showDiagnostics) + MaintenanceTab( + profile: profile, + session: session, + showDiagnostics: showDiagnostics, + diagnosticsText: diagnosticsText + ) case .settings: ScrollView { SettingsTab(profile: profile, session: session, appStore: appStore) @@ -48,5 +55,31 @@ struct DeviceDashboardView: View { .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + .alert( + session.flashStore.manualPowerCycleNotice?.title ?? "", + isPresented: manualPowerCycleNoticePresented, + presenting: session.flashStore.manualPowerCycleNotice + ) { notice in + Button(notice.actionTitle, role: .cancel) { + session.flashStore.dismissManualPowerCycleNotice() + } + } message: { notice in + Text(notice.message) + } + } + + private var manualPowerCycleNoticePresented: Binding { + Binding( + get: { session.flashStore.manualPowerCycleNotice != nil }, + set: { isPresented in + if !isPresented { + session.flashStore.dismissManualPowerCycleNotice() + } + } + ) + } + + private func diagnosticsText() -> String { + DiagnosticsExportBuilder().build(context: appStore.diagnosticsExportContext(includeBackendEvents: true)) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift index 22654a16..8ecfa745 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift @@ -2,14 +2,16 @@ import SwiftUI struct FlashBootHookSection: View { let profile: DeviceProfile - @StateObject private var store = FlashWorkflowStore() + @ObservedObject var store: FlashWorkflowStore + let performAction: (FlashUserAction) -> Void + let chooseFirmwareTemplate: () -> Void var body: some View { - let presentation = FlashPresentation(state: store.state, message: store.eligibilityMessage) - VStack(alignment: .leading, spacing: 8) { + let presentation = FlashPresentation(store: store) + VStack(alignment: .leading, spacing: 12) { Divider() - HStack { - VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { Text(presentation.title) .font(.headline) Text(presentation.message) @@ -17,16 +19,73 @@ struct FlashBootHookSection: View { .foregroundStyle(.secondary) } Spacer() - Label(presentation.stateTitle, systemImage: "lock") - .font(.caption) - .foregroundStyle(.secondary) + Text(presentation.stateTitle) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) + } + + if !presentation.warnings.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } + } } + + DisclosureGroup { + FlashFirmwareOptionsView(store: store, chooseFirmwareTemplate: chooseFirmwareTemplate) + } label: { + Label(L10n.string("flash.options.apple_firmware"), systemImage: "gearshape") + .font(.subheadline.weight(.medium)) + } + HStack { - ForEach(presentation.actions) { action in - Button(action.title) {} - .disabled(!presentation.isEnabled(action)) + ForEach(presentation.primaryActions) { action in + Button { + performAction(action) + } label: { + Label(presentation.title(for: action), systemImage: action.systemImage) + } + .disabled(!presentation.isEnabled(action)) } } + + HStack { + ForEach(presentation.secondaryActions) { action in + Button { + performAction(action) + } label: { + Label(presentation.title(for: action), systemImage: action.systemImage) + } + .disabled(!presentation.isEnabled(action)) + } + } + + if !presentation.rows.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(presentation.rows) { row in + HStack(alignment: .firstTextBaseline) { + Text(row.label) + .foregroundStyle(.secondary) + Spacer() + Text(row.value) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) + } + .font(.caption) + } + } + } + + if let timeline = FlashTimelinePresentation(events: store.events, currentStage: store.currentStage), + !timeline.items.isEmpty { + MaintenanceTimelineView(presentation: MaintenanceTimelinePresentation(items: timeline.items)) + } } .onAppear { store.refresh(profile: profile) @@ -36,3 +95,49 @@ struct FlashBootHookSection: View { } } } + +private struct FlashFirmwareOptionsView: View { + @ObservedObject var store: FlashWorkflowStore + let chooseFirmwareTemplate: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.string("field.firmware_version"), text: $store.firmwareVersion) + HStack { + TextField(L10n.string("field.firmware_template"), text: $store.firmwareTemplatePath) + Button { + chooseFirmwareTemplate() + } label: { + Label(L10n.string("flash.action.choose_template"), systemImage: "doc") + } + } + } + .textFieldStyle(.roundedBorder) + } +} + +private struct FlashTimelinePresentation: Equatable { + let items: [OperationTimelineItem] + + init?(events: [BackendEvent], currentStage: OperationStageState?) { + var items = OperationTimelineBuilder.timeline(from: events) + .filter { $0.operation == "flash" } + if items.isEmpty, let currentStage, currentStage.operation == "flash" { + items = [ + OperationTimelineItem( + id: "current:\(currentStage.operation):\(currentStage.stage)", + operation: currentStage.operation, + title: currentStage.stage, + detail: currentStage.description, + state: .running, + risk: currentStage.risk, + cancellable: currentStage.cancellable + ) + ] + } + guard !items.isEmpty else { + return nil + } + self.items = items + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift index 9b6f9bb5..ab0b2020 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift @@ -5,6 +5,7 @@ struct InstallTab: View { @ObservedObject var session: DeviceDashboardSession let appSettings: AppSettings let showDiagnostics: () -> Void + let diagnosticsText: () -> String var body: some View { let store = session.deployStore @@ -61,7 +62,7 @@ struct InstallTab: View { InstallExecutionOptionsView(store: store) if let error = store.error { - ErrorRecoveryView(error: error) { action in + ErrorRecoveryView(error: error, diagnosticsText: diagnosticsText) { action in handleRecovery(action: action, error: error) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift index 230299e5..7c77b5cb 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift @@ -5,6 +5,7 @@ struct MaintenanceTab: View { let profile: DeviceProfile @ObservedObject var session: DeviceDashboardSession let showDiagnostics: () -> Void + let diagnosticsText: () -> String var body: some View { let store = session.maintenanceStore @@ -31,11 +32,20 @@ struct MaintenanceTab: View { ) if FlashBootHookVisibilityPolicy.isVisible(for: profile) { - FlashBootHookSection(profile: profile) + FlashBootHookSection( + profile: profile, + store: session.flashStore, + performAction: { action in + session.performFlashAction(action, profile: profile) + }, + chooseFirmwareTemplate: { + chooseFirmwareTemplate(store: session.flashStore) + } + ) } if let error = store.error { - ErrorRecoveryView(error: error) { action in + ErrorRecoveryView(error: error, diagnosticsText: diagnosticsText) { action in handleRecovery(action: action, error: error) } } @@ -62,6 +72,17 @@ struct MaintenanceTab: View { store.repairPath = url.path } } + + private func chooseFirmwareTemplate(store: FlashWorkflowStore) { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.prompt = L10n.string("maintenance.action.choose") + if panel.runModal() == .OK, let url = panel.url { + store.firmwareTemplatePath = url.path + } + } } private struct MaintenanceWorkflowCardsView: View { @@ -274,7 +295,7 @@ private struct MaintenanceCompletionView: View { } } -private struct MaintenanceTimelineView: View { +struct MaintenanceTimelineView: View { let presentation: MaintenanceTimelinePresentation var body: some View { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift index 785f4c46..fb31f59b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift @@ -9,7 +9,13 @@ struct SettingsTab: View { VStack(alignment: .leading, spacing: 12) { Text(L10n.string("dashboard.tab.settings")) .font(.title2.weight(.semibold)) - DeviceProfileEditorView(profile: profile, store: session.profileEditorStore) + DeviceProfileEditorView( + profile: profile, + store: session.profileEditorStore, + diagnosticsText: { + DiagnosticsExportBuilder().build(context: appStore.diagnosticsExportContext(includeBackendEvents: true)) + } + ) SummaryGrid(rows: [ (L10n.string("advanced.profile_id"), profile.id), (L10n.string("advanced.config"), profile.configPath), @@ -23,6 +29,7 @@ struct SettingsTab: View { private struct DeviceProfileEditorView: View { let profile: DeviceProfile @ObservedObject var store: DeviceProfileEditorStore + let diagnosticsText: () -> String var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -77,7 +84,7 @@ private struct DeviceProfileEditorView: View { StageLine(stage: stage) } if let error = store.error { - ErrorRecoveryView(error: error) { _ in } + ErrorRecoveryView(error: error, diagnosticsText: diagnosticsText) { _ in } } } .onAppear { @@ -95,32 +102,38 @@ private struct DeviceProfileAdvancedSettingsView: View { var body: some View { DashboardDisclosureSection(title: L10n.string("profile_editor.advanced")) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Text(L10n.string("field.mount_wait")) - .foregroundStyle(.secondary) - TextField(L10n.string("field.mount_wait"), text: $store.draft.mountWaitSeconds) - .frame(width: 160) - } - GridRow { - Text(L10n.string("field.ata_idle_seconds")) - .foregroundStyle(.secondary) - TextField(L10n.string("field.ata_idle_seconds"), text: $store.draft.ataIdleSeconds) - .frame(width: 160) - } - GridRow { - Text(L10n.string("field.ata_standby")) - .foregroundStyle(.secondary) - TextField(L10n.string("field.ata_standby"), text: $store.draft.ataStandby) - .frame(width: 160) - } - GridRow { - Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) - Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $store.draft.internalShareUseDiskRoot) - } - GridRow { - Toggle(L10n.string("toggle.any_protocol"), isOn: $store.draft.anyProtocol) - Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("profile_editor.advanced.deploy_notice")) + .font(.caption) + .foregroundStyle(.secondary) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.draft.mountWaitSeconds) + .frame(width: 160) + } + GridRow { + Text(L10n.string("field.ata_idle_seconds")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.ata_idle_seconds"), text: $store.draft.ataIdleSeconds) + .frame(width: 160) + } + GridRow { + Text(L10n.string("field.ata_standby")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.ata_standby"), text: $store.draft.ataStandby) + .frame(width: 160) + } + GridRow { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) + Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $store.draft.internalShareUseDiskRoot) + } + GridRow { + Toggle(L10n.string("toggle.any_protocol"), isOn: $store.draft.anyProtocol) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) + } } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift index b5203eae..5619e046 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift @@ -1,4 +1,6 @@ +import AppKit import SwiftUI +import UniformTypeIdentifiers struct AppReadinessBannerView: View { @ObservedObject var store: AppReadinessStore @@ -8,7 +10,7 @@ struct AppReadinessBannerView: View { switch store.state { case .idle, .ready: EmptyView() - case .resolvingBundle, .checkingCapabilities, .validatingInstall: + case .resolvingBundle, .checkingVersion, .checkingCapabilities, .validatingInstall: HStack(spacing: 10) { ProgressView() .controlSize(.small) @@ -45,6 +47,8 @@ struct AppReadinessBannerView: View { switch store.state.kind { case .resolvingBundle: return L10n.string("readiness.state.resolving_bundle") + case .checkingVersion: + return L10n.string("readiness.state.checking_version") case .checkingCapabilities: return L10n.string("readiness.state.checking_capabilities") case .validatingInstall: @@ -91,10 +95,11 @@ struct AppReadinessBlockedView: View { struct AppDiagnosticsView: View { @ObservedObject var store: AppReadinessStore - let events: [BackendEvent] + let exportContext: (_ includeBackendEvents: Bool) -> DiagnosticsExportContext @Binding var showBackendEvents: Bool @Binding var helperPath: String @Environment(\.dismiss) private var dismiss + @State private var exportStatus: String? var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -102,6 +107,21 @@ struct AppDiagnosticsView: View { Text(L10n.string("diagnostics.title")) .font(.title2.weight(.semibold)) Spacer() + if let exportStatus { + Text(exportStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + Button { + copyDiagnostics() + } label: { + Label(L10n.string("diagnostics.copy"), systemImage: "doc.on.doc") + } + Button { + saveDiagnostics() + } label: { + Label(L10n.string("diagnostics.save"), systemImage: "square.and.arrow.down") + } Button(L10n.string("action.done")) { dismiss() } @@ -154,12 +174,45 @@ struct AppDiagnosticsView: View { Toggle(L10n.string("diagnostics.backend_events"), isOn: $showBackendEvents) .font(.headline) if showBackendEvents { - EventList(events: events) + EventList(events: exportContext(true).events) } } .padding() .frame(minWidth: 720, minHeight: 520) } + + private func copyDiagnostics() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(exportText(), forType: .string) + exportStatus = L10n.string("diagnostics.copied") + } + + private func saveDiagnostics() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "TimeCapsuleSMB-Diagnostics.txt" + panel.allowedContentTypes = [.plainText] + let text = exportText() + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + do { + try text.write(to: url, atomically: true, encoding: .utf8) + Task { @MainActor in + exportStatus = L10n.string("diagnostics.saved") + } + } catch { + Task { @MainActor in + exportStatus = error.localizedDescription + } + } + } + } + + private func exportText() -> String { + DiagnosticsExportBuilder().build(context: exportContext(showBackendEvents)) + } } struct EventList: View { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift index ed694884..d7e6a3c2 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift @@ -139,7 +139,7 @@ struct AppSettingsView: View { private var updateStatusColor: Color { switch appStore.appUpdateStore.state { - case .updateAvailable, .failed: + case .updateAvailable, .unavailable, .failed: return .yellow case .current: return .green diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift index 26cf6503..4ea206d0 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift @@ -104,7 +104,9 @@ public struct ContentView: View { .sheet(isPresented: $diagnosticsPresented) { AppDiagnosticsView( store: appStore.appReadinessStore, - events: appStore.backend.events, + exportContext: { includeBackendEvents in + appStore.diagnosticsExportContext(includeBackendEvents: includeBackendEvents) + }, showBackendEvents: $diagnosticsShowBackendEvents, helperPath: Binding( get: { appStore.backend.helperPath }, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppReadinessStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppReadinessStore.swift index 761ea59c..f2a30d52 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppReadinessStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppReadinessStore.swift @@ -4,6 +4,7 @@ import Foundation enum AppReadinessStateKind: String, CaseIterable, Equatable { case idle case resolvingBundle + case checkingVersion case checkingCapabilities case validatingInstall case ready @@ -16,6 +17,8 @@ enum AppReadinessStateKind: String, CaseIterable, Equatable { return L10n.string("app_readiness.state.idle") case .resolvingBundle: return L10n.string("app_readiness.state.resolving_bundle") + case .checkingVersion: + return L10n.string("app_readiness.state.checking_version") case .checkingCapabilities: return L10n.string("app_readiness.state.checking_capabilities") case .validatingInstall: @@ -41,6 +44,7 @@ struct AppReadinessSummary: Equatable { enum AppReadinessState: Equatable { case idle case resolvingBundle + case checkingVersion case checkingCapabilities case validatingInstall case ready(AppReadinessSummary) @@ -53,6 +57,8 @@ enum AppReadinessState: Equatable { return .idle case .resolvingBundle: return .resolvingBundle + case .checkingVersion: + return .checkingVersion case .checkingCapabilities: return .checkingCapabilities case .validatingInstall: @@ -67,6 +73,14 @@ enum AppReadinessState: Equatable { } } +struct AppReadinessVersionCheck: Equatable { + var url: String + + func params() -> [String: JSONValue] { + OperationParams.versionCheck(url: url) + } +} + protocol AppRuntimeResolving { func resolve(helperPath: String?) throws -> HelperResolution func runtimeIssues(for resolution: HelperResolution) -> [BundleRuntimeIssue] @@ -79,6 +93,7 @@ final class AppReadinessStore: ObservableObject { @Published private(set) var state: AppReadinessState = .idle @Published private(set) var capabilities: CapabilitiesPayload? @Published private(set) var validation: InstallValidationPayload? + @Published private(set) var versionCheckPayload: VersionCheckPayload? @Published private(set) var issues: [BundleRuntimeIssue] = [] @Published private(set) var currentStage: OperationStageState? @@ -87,7 +102,8 @@ final class AppReadinessStore: ObservableObject { private let runtimeResolver: any AppRuntimeResolving private let helperPathProvider: () -> String private var runtimeMode: BundleRuntimeMode = .developmentCheckout - private var pendingOperation: String? + private var versionCheck: AppReadinessVersionCheck? + private var pendingOperation: PendingReadinessOperation? private var lastProcessedEventCount = 0 private var cancellables: Set = [] @@ -128,11 +144,16 @@ final class AppReadinessStore: ObservableObject { !backend.isRunning } + func applyVersionCheck(_ versionCheck: AppReadinessVersionCheck?) { + self.versionCheck = versionCheck + } + func start() { guard !backend.isRunning else { return } backend.clear() capabilities = nil validation = nil + versionCheckPayload = nil issues = [] currentStage = nil pendingOperation = nil @@ -158,14 +179,19 @@ final class AppReadinessStore: ObservableObject { return } - state = .checkingCapabilities - backend.run(operation: "capabilities") + if let versionCheck { + pendingOperation = PendingReadinessOperation(operation: "version-check", params: versionCheck.params()) + } else { + pendingOperation = PendingReadinessOperation(operation: "capabilities") + } + runPendingOperation() } func clear() { backend.clear() capabilities = nil validation = nil + versionCheckPayload = nil issues = [] currentStage = nil pendingOperation = nil @@ -187,7 +213,7 @@ final class AppReadinessStore: ObservableObject { } private func handle(_ event: BackendEvent) { - guard ["capabilities", "validate-install"].contains(event.operation) else { + guard ["version-check", "capabilities", "validate-install"].contains(event.operation) else { return } @@ -197,6 +223,12 @@ final class AppReadinessStore: ObservableObject { } if event.type == "error" { + if event.operation == "version-check" { + issues.append(versionMetadataIssue(message: event.message ?? event.summary)) + pendingOperation = PendingReadinessOperation(operation: "capabilities") + runPendingOperation() + return + } state = .blocked(issue(from: event)) return } @@ -206,6 +238,8 @@ final class AppReadinessStore: ObservableObject { } switch event.operation { + case "version-check": + applyVersionCheckResult(event) case "capabilities": applyCapabilitiesResult(event) case "validate-install": @@ -215,6 +249,35 @@ final class AppReadinessStore: ObservableObject { } } + private func applyVersionCheckResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(VersionCheckPayload.self) + versionCheckPayload = payload + guard event.ok == true else { + issues.append(versionMetadataIssue(message: payload.summary)) + pendingOperation = PendingReadinessOperation(operation: "capabilities") + runPendingOperation() + return + } + if payload.source == "unavailable" { + issues.append(versionMetadataIssue(message: payload.summary)) + } + guard !payload.shouldBlock else { + state = .blocked(BundleRuntimeIssue( + code: .unsupportedVersion, + severity: .error, + message: payload.message, + recovery: L10n.format("app_readiness.recovery.update_required", payload.downloadURL) + )) + return + } + pendingOperation = PendingReadinessOperation(operation: "capabilities") + runPendingOperation() + } catch { + state = .blocked(contractIssue(operation: "version-check", error: error)) + } + } + private func applyCapabilitiesResult(_ event: BackendEvent) { do { let payload = try event.decodePayload(CapabilitiesPayload.self) @@ -228,7 +291,7 @@ final class AppReadinessStore: ObservableObject { )) return } - pendingOperation = "validate-install" + pendingOperation = PendingReadinessOperation(operation: "validate-install") runPendingOperation() } catch { state = .blocked(contractIssue(operation: "capabilities", error: error)) @@ -267,14 +330,18 @@ final class AppReadinessStore: ObservableObject { } private func runPendingOperation() { - guard let operation = pendingOperation, !backend.isRunning else { + guard let pending = pendingOperation, !backend.isRunning else { return } pendingOperation = nil - if operation == "validate-install" { + if pending.operation == "version-check" { + state = .checkingVersion + } else if pending.operation == "capabilities" { + state = .checkingCapabilities + } else if pending.operation == "validate-install" { state = .validatingInstall } - backend.run(operation: operation) + backend.run(operation: pending.operation, params: pending.params) } private func issue(from event: BackendEvent) -> BundleRuntimeIssue { @@ -304,8 +371,27 @@ final class AppReadinessStore: ObservableObject { ) } + private func versionMetadataIssue(message: String) -> BundleRuntimeIssue { + BundleRuntimeIssue( + code: .versionMetadataUnavailable, + severity: .warning, + message: message, + recovery: L10n.string("app_readiness.recovery.version_metadata_unavailable") + ) + } + private func normalized(_ value: String) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } } + +private struct PendingReadinessOperation { + let operation: String + let params: [String: JSONValue] + + init(operation: String, params: [String: JSONValue] = [:]) { + self.operation = operation + self.params = params + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift index f88e4e0a..ce1d64c2 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift @@ -5,6 +5,7 @@ enum AppUpdateState: String, Equatable { case idle case checking case current + case unavailable case updateAvailable case failed @@ -16,6 +17,8 @@ enum AppUpdateState: String, Equatable { return L10n.string("app_update.state.checking") case .current: return L10n.string("app_update.state.current") + case .unavailable: + return L10n.string("app_update.state.unavailable") case .updateAvailable: return L10n.string("app_update.state.update_available") case .failed: @@ -69,12 +72,7 @@ final class AppUpdateStore: ObservableObject { error = nil currentStage = nil - var params: [String: JSONValue] = [:] - let url = settings.versionCheckURL.trimmingCharacters(in: .whitespacesAndNewlines) - if !url.isEmpty { - params["url"] = .string(url) - } - + let params = OperationParams.versionCheck(url: settings.versionCheckURL) switch lane.run(operation: "version-check", params: params, context: nil, activeDeviceID: nil) { case .started(let operation): activeOperation = operation @@ -125,7 +123,13 @@ final class AppUpdateStore: ObservableObject { do { let result = try event.decodePayload(VersionCheckPayload.self) payload = result - state = result.shouldBlock ? .updateAvailable : .current + if result.shouldBlock { + state = .updateAvailable + } else if result.source == "unavailable" { + state = .unavailable + } else { + state = .current + } error = nil activeOperation = nil } catch { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index c2afe594..935b54f9 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -13,6 +13,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { var deployStore: DeployWorkflowStore var doctorStore: DoctorStore var maintenanceStore: MaintenanceStore + var flashStore: FlashWorkflowStore let profileEditorStore: DeviceProfileEditorStore private let urlOpener: URLOpening @@ -43,6 +44,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { self.deployStore = DeployWorkflowStore(coordinator: appStore.operationCoordinator, laneKey: laneKey) self.doctorStore = DoctorStore(coordinator: appStore.operationCoordinator, laneKey: laneKey) self.maintenanceStore = MaintenanceStore(coordinator: appStore.operationCoordinator, laneKey: laneKey) + self.flashStore = FlashWorkflowStore(coordinator: appStore.operationCoordinator, laneKey: laneKey) self.profileEditorStore = DeviceProfileEditorStore(profile: profile, appStore: appStore) applyProfileSettings(profile.settings) forwardChildChanges() @@ -168,6 +170,31 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } } + func performFlashAction(_ action: FlashUserAction, profile: DeviceProfile) { + switch action { + case .backupAndInspect: + if let password = maintenancePassword(for: profile) { + flashStore.backupAndInspect(password: password, profile: profile) + } + case .planPatch: + flashStore.planFlash(mode: .patch, profile: profile) + case .planRestore: + flashStore.planFlash(mode: .restore, profile: profile) + case .checkApple: + flashStore.planFlash(mode: .checkApple, profile: profile) + case .downloadApple: + flashStore.planFlash(mode: .downloadOnly, profile: profile) + case .writePatch: + if let password = maintenancePassword(for: profile) { + flashStore.write(mode: .patch, password: password, profile: profile) + } + case .writeRestore: + if let password = maintenancePassword(for: profile) { + flashStore.write(mode: .restore, password: password, profile: profile) + } + } + } + func saveReplacementPassword(for profile: DeviceProfile) async { let password = replacementPassword guard !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -332,6 +359,15 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } } .store(in: &cancellables) + flashStore.$passwordInvalidProfileID + .sink { [weak self] profileID in + guard let profileID else { return } + Task { @MainActor [weak self] in + guard let self else { return } + await self.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) + } + } + .store(in: &cancellables) maintenanceStore.$uninstallState .sink { [weak self] state in Task { @MainActor in @@ -402,6 +438,11 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { self?.objectWillChange.send() } .store(in: &cancellables) + flashStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) profileEditorStore.objectWillChange .sink { [weak self] _ in self?.objectWillChange.send() diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift index 5e466889..fad9ec12 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift @@ -1,9 +1,13 @@ import Foundation -enum FlashUserAction: String, Equatable, Identifiable { +enum FlashUserAction: String, Hashable, Identifiable { case backupAndInspect - case patchBootHook - case restoreFirmware + case planPatch + case planRestore + case checkApple + case downloadApple + case writePatch + case writeRestore var id: String { rawValue } @@ -11,10 +15,48 @@ enum FlashUserAction: String, Equatable, Identifiable { switch self { case .backupAndInspect: return L10n.string("flash.action.backup_inspect") - case .patchBootHook: - return L10n.string("flash.action.patch_boot_hook") - case .restoreFirmware: - return L10n.string("flash.action.restore_firmware") + case .planPatch: + return L10n.string("flash.action.plan_patch") + case .planRestore: + return L10n.string("flash.action.plan_restore") + case .checkApple: + return L10n.string("flash.action.check_apple") + case .downloadApple: + return L10n.string("flash.action.download_apple") + case .writePatch: + return L10n.string("flash.action.write_patch") + case .writeRestore: + return L10n.string("flash.action.write_restore") + } + } + + var systemImage: String { + switch self { + case .backupAndInspect: + return "externaldrive.badge.questionmark" + case .planPatch, .planRestore: + return "doc.text.magnifyingglass" + case .checkApple: + return "checkmark.seal" + case .downloadApple: + return "checkmark.shield" + case .writePatch: + return "bolt.trianglebadge.exclamationmark" + case .writeRestore: + return "arrow.uturn.backward.circle" + } + } + + static func planAction(for mode: FlashPlanMode) -> FlashUserAction { + switch mode { + case .patch: + return .planPatch + case .restore: + return .planRestore + case .checkApple: + return .checkApple + case .downloadOnly: + return .downloadApple } } } @@ -23,27 +65,146 @@ struct FlashPresentation: Equatable { let title: String let message: String let stateTitle: String - let actions: [FlashUserAction] + let primaryActions: [FlashUserAction] + let secondaryActions: [FlashUserAction] let enabledActions: Set + let rows: [PresentationRow] + let warnings: [String] + private let backupActionTitle: String - init(state: FlashWorkflowState, message: String) { + @MainActor + init(store: FlashWorkflowStore) { self.title = L10n.string("flash.title") - self.message = message - self.stateTitle = state.title - self.actions = [.backupAndInspect, .patchBootHook, .restoreFirmware] - switch state { - case .eligibleForReadOnlyAnalysis, .planAvailable: - self.enabledActions = [.backupAndInspect] - case .writeLocked, .awaitingStrongConfirmation: - self.enabledActions = [.backupAndInspect] - case .writing, .readbackValidating, .writeValidated, .manualPowerCycleRequired, .restoreRebooting: - self.enabledActions = [] - case .unavailable, .disabledInThisBuild, .readingBanks, .savingBackup, .analyzingBanks, .failed: - self.enabledActions = [] - } + self.message = store.error?.message ?? store.writeResult?.summary ?? store.plan?.summary ?? store.backup?.summary ?? store.eligibilityMessage + self.stateTitle = store.state.title + self.primaryActions = [.backupAndInspect, .planPatch, .planRestore, .writePatch, .writeRestore] + self.secondaryActions = [.checkApple, .downloadApple] + self.enabledActions = Self.enabledActions(store: store) + self.rows = Self.rows(store: store) + self.warnings = Self.warnings(store: store) + self.backupActionTitle = store.backupSnapshotStale + ? L10n.string("flash.action.backup_inspect_again") + : L10n.string("flash.action.backup_inspect") } func isEnabled(_ action: FlashUserAction) -> Bool { enabledActions.contains(action) } + + func title(for action: FlashUserAction) -> String { + action == .backupAndInspect ? backupActionTitle : action.title + } + + @MainActor + private static func enabledActions(store: FlashWorkflowStore) -> Set { + var actions: Set = [] + if store.canBackup { + actions.insert(.backupAndInspect) + } + if store.canPlanWrites { + actions.formUnion([.planPatch, .planRestore]) + } + if store.canPlan { + actions.formUnion([.checkApple, .downloadApple]) + } + if store.canWritePatch { + actions.insert(.writePatch) + } + if store.canWriteRestore { + actions.insert(.writeRestore) + } + return actions + } + + @MainActor + private static func rows(store: FlashWorkflowStore) -> [PresentationRow] { + var rows: [PresentationRow] = [] + if let backup = store.backup { + rows.append(PresentationRow(label: L10n.string("flash.row.backup_dir"), value: backup.backupDir)) + rows.append(PresentationRow(label: L10n.string("flash.row.active_bank"), value: backup.activeBank ?? L10n.string("value.unknown"))) + rows.append(PresentationRow(label: L10n.string("flash.row.banks"), value: "\(backup.banks.count)")) + } + if let plan = store.plan { + rows.append(PresentationRow(label: L10n.string("flash.row.mode"), value: plan.mode.title)) + rows.append(PresentationRow(label: L10n.string("flash.row.write_requested"), value: plan.writeRequested ? L10n.string("value.yes") : L10n.string("value.no"))) + if let match = plan.appleFirmwareMatch { + rows.append(PresentationRow(label: L10n.string("flash.row.apple_match"), value: match.matched ? L10n.string("value.yes") : L10n.string("value.no"))) + appendIfPresent(&rows, label: L10n.string("flash.row.apple_version"), value: match.templateVersion) + appendIfPresent(&rows, label: L10n.string("flash.row.apple_product"), value: match.templateProductID) + appendIfPresent(&rows, label: L10n.string("flash.row.apple_source"), value: match.templateSource) + appendIfPresent(&rows, label: L10n.string("flash.row.apple_payload_sha256"), value: match.innerSHA256) + } + if let payload = plan.firmwarePayload { + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_version"), value: payload.templateVersion) + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_product"), value: payload.templateProductID) + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_source"), value: payload.templateSource) + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_payload_path"), value: plan.firmwarePayloadPath) + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_payload_sha256"), value: payload.payloadSHA256) + if let payloadSize = payload.payloadSize { + rows.append(PresentationRow(label: L10n.string("flash.row.firmware_payload_size"), value: Self.byteCount(payloadSize))) + } + } + } + if let result = store.writeResult { + rows.append(PresentationRow(label: L10n.string("flash.row.write_status"), value: result.writeStatus)) + rows.append(PresentationRow(label: L10n.string("flash.row.write_validated"), value: result.writeValidated ? L10n.string("value.yes") : L10n.string("value.no"))) + } + return rows + } + + private static func appendIfPresent(_ rows: inout [PresentationRow], label: String, value: String?) { + guard let value else { + return + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return + } + rows.append(PresentationRow(label: label, value: trimmed)) + } + + private static func byteCount(_ value: Int) -> String { + ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file) + } + + @MainActor + private static func warnings(store: FlashWorkflowStore) -> [String] { + var warnings: [String] = [] + if store.backupSnapshotStale { + warnings.append(L10n.string("flash.warning.snapshot_stale")) + } + if store.state == .manualPowerCycleRequired || store.state == .writeValidatedSnapshotStale { + warnings.append(L10n.string("flash.warning.manual_power_cycle")) + } + return warnings + } +} + +extension FlashManualPowerCycleNotice { + var title: String { + L10n.string("flash.manual_power_cycle.title") + } + + var message: String { + L10n.string("flash.manual_power_cycle.message") + } + + var actionTitle: String { + L10n.string("action.ok") + } +} + +extension FlashPlanMode { + var title: String { + switch self { + case .patch: + return L10n.string("flash.mode.patch") + case .restore: + return L10n.string("flash.mode.restore") + case .checkApple: + return L10n.string("flash.mode.check_apple") + case .downloadOnly: + return L10n.string("flash.mode.download_only") + } + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift index 7a345e73..c7cb12f7 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift @@ -1,3 +1,4 @@ +import Combine import Foundation enum FlashBuildPolicy: String, CaseIterable, Equatable { @@ -6,6 +7,19 @@ enum FlashBuildPolicy: String, CaseIterable, Equatable { case writesEnabled } +enum FlashPlanMode: String, Codable, CaseIterable, Equatable, Identifiable { + case patch + case restore + case checkApple = "check_apple" + case downloadOnly = "download_only" + + var id: String { rawValue } + + var writesFirmware: Bool { + self == .patch || self == .restore + } +} + enum FlashWorkflowState: String, CaseIterable, Equatable { case unavailable case disabledInThisBuild @@ -14,11 +28,15 @@ enum FlashWorkflowState: String, CaseIterable, Equatable { case savingBackup case analyzingBanks case planAvailable + case appleCheckComplete + case appleFirmwareMismatch + case appleFirmwareReady case writeLocked case awaitingStrongConfirmation case writing case readbackValidating case writeValidated + case writeValidatedSnapshotStale case manualPowerCycleRequired case restoreRebooting case failed @@ -39,16 +57,24 @@ enum FlashWorkflowState: String, CaseIterable, Equatable { return "Analyzing Firmware" case .planAvailable: return "Plan Available" + case .appleCheckComplete: + return "Apple Check Complete" + case .appleFirmwareMismatch: + return "Apple Firmware Mismatch" + case .appleFirmwareReady: + return "Apple Firmware Ready" case .writeLocked: - return "Write Locked" + return "Ready" case .awaitingStrongConfirmation: - return "Awaiting Strong Confirmation" + return "Awaiting Confirmation" case .writing: return "Writing Firmware" case .readbackValidating: return "Validating Write" case .writeValidated: return "Write Validated" + case .writeValidatedSnapshotStale: + return "Snapshot Stale" case .manualPowerCycleRequired: return "Manual Power Cycle Required" case .restoreRebooting: @@ -67,7 +93,7 @@ struct FlashEligibility: Equatable { } enum FlashEligibilityPolicy { - static func eligibility(for profile: DeviceProfile, buildPolicy: FlashBuildPolicy = .disabled) -> FlashEligibility { + static func eligibility(for profile: DeviceProfile, buildPolicy: FlashBuildPolicy = .writesEnabled) -> FlashEligibility { guard profile.traits.supportsFlashBootHook else { return FlashEligibility( state: .unavailable, @@ -81,21 +107,21 @@ enum FlashEligibilityPolicy { case .disabled: return FlashEligibility( state: .disabledInThisBuild, - message: "Firmware boot hook analysis is planned, but disabled in this build.", + message: "Firmware boot hook analysis is 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.", + message: "This NetBSD4 device can be backed up and inspected before any write 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.", + message: "Back up and inspect firmware before planning a patch or restore write.", readOnlyAllowed: true, writeAllowed: true ) @@ -109,20 +135,515 @@ enum FlashBootHookVisibilityPolicy { } } +struct FlashManualPowerCycleNotice: Identifiable, Equatable { + let id = UUID() + let mode: FlashPlanMode +} + +private struct FlashFirmwareSelection: Equatable { + let version: String + let templatePath: String +} + @MainActor final class FlashWorkflowStore: ObservableObject { - @Published private(set) var state: FlashWorkflowState = .disabledInThisBuild - @Published private(set) var eligibilityMessage = "Firmware boot hook analysis is disabled in this build." + @Published private(set) var state: FlashWorkflowState = .writeLocked + @Published private(set) var eligibilityMessage = "Back up and inspect firmware before planning a patch or restore write." + @Published private(set) var backup: FlashBackupPayload? + @Published private(set) var plan: FlashPlanPayload? + @Published private(set) var writeResult: FlashWritePayload? + @Published private(set) var backupSnapshotStale = false + @Published private(set) var manualPowerCycleNotice: FlashManualPowerCycleNotice? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? + @Published var firmwareVersion = "" { + didSet { + invalidatePlanIfFirmwareSelectionChanged() + } + } + @Published var firmwareTemplatePath = "" { + didSet { + invalidatePlanIfFirmwareSelectionChanged() + } + } let buildPolicy: FlashBuildPolicy + let backend: BackendClient + private let coordinator: OperationCoordinator? + private let laneKey: OperationLaneKey? + private var eligibility = FlashEligibility( + state: .writeLocked, + message: "Back up and inspect firmware before planning a patch or restore write.", + readOnlyAllowed: true, + writeAllowed: true + ) + private var activeOperation: ActiveOperation? + private var activeAction: FlashUserAction? + private var pendingFirmwareSelection: FlashFirmwareSelection? + private var plannedFirmwareSelection: FlashFirmwareSelection? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init(buildPolicy: FlashBuildPolicy = .writesEnabled) { + self.init(backend: BackendClient(), buildPolicy: buildPolicy) + } - init(buildPolicy: FlashBuildPolicy = .disabled) { + init(backend: BackendClient, buildPolicy: FlashBuildPolicy = .writesEnabled) { + self.backend = backend + self.coordinator = nil + self.laneKey = nil self.buildPolicy = buildPolicy + observeBackend(backend) + } + + convenience init(coordinator: OperationCoordinator, laneKey: OperationLaneKey, buildPolicy: FlashBuildPolicy = .writesEnabled) { + let lane = coordinator.lane(for: laneKey) + self.init(backend: lane.backend, coordinator: coordinator, laneKey: laneKey, buildPolicy: buildPolicy) + } + + private init( + backend: BackendClient, + coordinator: OperationCoordinator?, + laneKey: OperationLaneKey?, + buildPolicy: FlashBuildPolicy + ) { + self.backend = backend + self.coordinator = coordinator + self.laneKey = laneKey + self.buildPolicy = buildPolicy + observeBackend(backend) + } + + private func observeBackend(_ backend: BackendClient) { + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + + var canBackup: Bool { + !isBusy && eligibility.readOnlyAllowed + } + + var canPlan: Bool { + !isBusy && eligibility.readOnlyAllowed && backup != nil && !backupSnapshotStale + } + + var canPlanWrites: Bool { + canPlan && eligibility.writeAllowed + } + + var canWritePatch: Bool { + canWrite(mode: .patch) + } + + var canWriteRestore: Bool { + canWrite(mode: .restore) } func refresh(profile: DeviceProfile) { - let eligibility = FlashEligibilityPolicy.eligibility(for: profile, buildPolicy: buildPolicy) - state = eligibility.state + eligibility = FlashEligibilityPolicy.eligibility(for: profile, buildPolicy: buildPolicy) eligibilityMessage = eligibility.message + if backup == nil, plan == nil, writeResult == nil, activeOperation == nil { + state = eligibility.state + } + } + + @discardableResult + func backupAndInspect(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard canBackup else { + return reject("Flash backup is not available.") + } + let start = startRun( + action: .backupAndInspect, + params: OperationParams.flashBackup(password: password), + profile: profile + ) + guard case .started = start else { + return start + } + state = .readingBanks + backup = nil + plan = nil + writeResult = nil + backupSnapshotStale = false + pendingFirmwareSelection = nil + plannedFirmwareSelection = nil + return start + } + + @discardableResult + func planFlash(mode: FlashPlanMode, profile: DeviceProfile? = nil) -> OperationStartResult { + guard canPlan else { + return reject("Back up and inspect firmware before planning flash work.") + } + if mode.writesFirmware, !canPlanWrites { + return reject("Firmware writes are disabled in this build.") + } + guard let backupDir = backup?.backupDir else { + return reject("Back up and inspect firmware before planning flash work.") + } + let action = FlashUserAction.planAction(for: mode) + let selection = currentFirmwareSelection + let start = startRun( + action: action, + params: OperationParams.flashPlan( + backupDir: backupDir, + mode: mode, + firmwareVersion: selection.version, + firmwareTemplate: selection.templatePath + ), + profile: profile + ) + guard case .started = start else { + return start + } + pendingFirmwareSelection = selection + state = .analyzingBanks + plan = nil + writeResult = nil + return start + } + + @discardableResult + func write(mode: FlashPlanMode, password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard mode.writesFirmware else { + return reject("This flash mode does not write firmware.") + } + guard !isBusy else { + return reject("Another operation is already running.") + } + guard let plan, plan.mode == mode, plan.writeRequested, let backupDir = backup?.backupDir else { + state = .writeLocked + return reject("Plan the selected flash write before running it.") + } + let selection = currentFirmwareSelection + guard plannedFirmwareSelection == selection else { + return reject("Plan the selected flash write again after changing Apple firmware options.") + } + let action = mode == .patch ? FlashUserAction.writePatch : .writeRestore + let start = startRun( + action: action, + params: OperationParams.flashWrite( + backupDir: backupDir, + mode: mode, + firmwareVersion: selection.version, + firmwareTemplate: selection.templatePath, + password: password + ), + profile: profile + ) + guard case .started = start else { + return start + } + state = .writing + writeResult = nil + return start + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + state = eligibility.state + backup = nil + plan = nil + writeResult = nil + backupSnapshotStale = false + manualPowerCycleNotice = nil + currentStage = nil + error = nil + passwordInvalidProfileID = nil + activeOperation = nil + activeAction = nil + pendingFirmwareSelection = nil + plannedFirmwareSelection = nil + } + + func dismissManualPowerCycleNotice() { + manualPowerCycleNotice = nil + } + + private func canWrite(mode: FlashPlanMode) -> Bool { + !isBusy + && eligibility.writeAllowed + && plan?.mode == mode + && plan?.writeRequested == true + && backup != nil + && !backupSnapshotStale + && plannedFirmwareSelection == currentFirmwareSelection + && [.planAvailable, .failed].contains(state) + } + + private func process(_ events: [BackendEvent]) { + 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 == "flash" else { + return + } + guard activeOperation?.operation == event.operation else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + state = stateForStage(stage.stage) + return + } + + if event.type == "error" { + applyError(event) + return + } + + guard event.type == "result" else { + return + } + if event.ok == false { + applyFalseResult(event) + return + } + applyResult(event) + } + + private func applyResult(_ event: BackendEvent) { + do { + switch activeAction { + case .backupAndInspect: + backup = try event.decodePayload(FlashBackupPayload.self) + backupSnapshotStale = false + plannedFirmwareSelection = nil + state = .planAvailable + case .planPatch, .planRestore, .checkApple, .downloadApple: + plan = try event.decodePayload(FlashPlanPayload.self) + if let plan { + plannedFirmwareSelection = pendingFirmwareSelection ?? currentFirmwareSelection + pendingFirmwareSelection = nil + if plannedFirmwareSelection == currentFirmwareSelection { + state = Self.stateAfterPlan(plan) + } else { + self.plan = nil + state = backup == nil ? eligibility.state : .planAvailable + } + } + case .writePatch, .writeRestore: + let result = try event.decodePayload(FlashWritePayload.self) + writeResult = result + if Self.writeMayHaveModifiedFirmware(result) { + markSnapshotStaleAfterWrite() + manualPowerCycleNotice = FlashManualPowerCycleNotice(mode: result.mode) + } else { + state = .writeValidated + } + case nil: + break + } + error = nil + currentStage = nil + activeOperation = nil + activeAction = nil + pendingFirmwareSelection = nil + } catch { + self.error = BackendErrorViewModel( + operation: "flash", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .failed + activeOperation = nil + activeAction = nil + pendingFirmwareSelection = nil + } + } + + private func markSnapshotStaleAfterWrite() { + backupSnapshotStale = true + plan = nil + plannedFirmwareSelection = nil + state = .writeValidatedSnapshotStale + } + + private static func writeMayHaveModifiedFirmware(_ result: FlashWritePayload) -> Bool { + result.writeMayHaveModifiedDevice + || (result.writeValidated && (result.mode == .patch || result.mode == .restore)) + } + + private static func stateAfterPlan(_ plan: FlashPlanPayload) -> FlashWorkflowState { + switch plan.mode { + case .checkApple: + return plan.appleFirmwareMatch?.matched == false ? .appleFirmwareMismatch : .appleCheckComplete + case .downloadOnly: + return .appleFirmwareReady + case .patch, .restore: + return .planAvailable + } + } + + private func applyError(_ event: BackendEvent) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingStrongConfirmation + return + } + if event.code == "confirmation_cancelled" { + error = nil + currentStage = nil + activeOperation = nil + activeAction = nil + pendingFirmwareSelection = nil + state = plan == nil ? (backup == nil ? eligibility.state : .writeLocked) : .planAvailable + return + } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation?.profileID + } + if activeAction == .writePatch || activeAction == .writeRestore, + currentStageMayHaveModifiedFirmware() { + markSnapshotStaleAfterWrite() + } + error = BackendErrorViewModel(event: event) + state = .failed + activeOperation = nil + activeAction = nil + pendingFirmwareSelection = nil + } + + private var currentFirmwareSelection: FlashFirmwareSelection { + FlashFirmwareSelection( + version: firmwareVersion.trimmingCharacters(in: .whitespacesAndNewlines), + templatePath: firmwareTemplatePath.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + private func invalidatePlanIfFirmwareSelectionChanged() { + guard !isBusy, plan != nil, plannedFirmwareSelection != currentFirmwareSelection else { + return + } + plan = nil + writeResult = nil + plannedFirmwareSelection = nil + if backup != nil, !backupSnapshotStale { + state = .planAvailable + } + } + + private func currentStageMayHaveModifiedFirmware() -> Bool { + guard currentStage?.operation == "flash" else { + return false + } + return currentStage?.stage == "write_primary_bank" + || currentStage?.stage == "write_active_bank" + || currentStage?.stage == "post_write_validation" + } + + private func applyFalseResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + state = .failed + activeOperation = nil + activeAction = nil + } + + private func stateForStage(_ stage: String) -> FlashWorkflowState { + switch stage { + case "read_flash": + return .readingBanks + case "save_raw_backup": + return .savingBackup + case "inspect_backup", "analyze_flash", "plan_flash": + return .analyzingBanks + case "confirm_write": + return .awaitingStrongConfirmation + case "pre_write_validation", "post_write_validation": + return .readbackValidating + case "write_primary_bank", "write_active_bank": + return .writing + default: + return state + } + } + + private func reject(_ message: String) -> OperationStartResult { + error = BackendErrorViewModel(operation: "flash", code: "operation_rejected", message: message) + state = .failed + return .rejected(message) + } + + private func startRun( + action: FlashUserAction, + params: [String: JSONValue], + profile: DeviceProfile? + ) -> OperationStartResult { + guard !isBusy else { + return reject("Another operation is already running.") + } + resetRunState() + let start = run(operation: "flash", params: params, profile: profile) + switch start { + case .started(let operation): + activeOperation = operation + activeAction = action + case .rejected(let message): + return reject(message) + } + return start + } + + private func resetRunState() { + backend.clear() + lastProcessedEventCount = 0 + error = nil + manualPowerCycleNotice = nil + currentStage = nil + passwordInvalidProfileID = nil + activeOperation = nil + activeAction = nil + } + + private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { + if let coordinator { + return coordinator.run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + laneKey: laneKey ?? profile.map { .device($0.id) } ?? .app + ) + } + guard !isBusy else { + return .rejected("Another operation is already running.") + } + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: profile?.runtimeContext) + backend.run(operation: operation, params: params, context: profile?.runtimeContext) + return .started(activeOperation) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift index 2a796d84..11d40e5c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift @@ -85,6 +85,10 @@ struct MaintenanceCompletionPresentation: Equatable { struct MaintenanceTimelinePresentation: Equatable { let items: [OperationTimelineItem] + init(items: [OperationTimelineItem]) { + self.items = items + } + init(events: [BackendEvent], currentStage: OperationStageState?, workflow: MaintenanceWorkflow) { let operation = workflow.operationName var items = OperationTimelineBuilder.timeline(from: events) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift index 04d2ed22..4ba92e1a 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift @@ -6,7 +6,7 @@ final class AppReadinessStoreTests: XCTestCase { func testStateInventoryIsExplicit() { XCTAssertEqual( AppReadinessStateKind.allCases, - [.idle, .resolvingBundle, .checkingCapabilities, .validatingInstall, .ready, .degraded, .blocked] + [.idle, .resolvingBundle, .checkingVersion, .checkingCapabilities, .validatingInstall, .ready, .degraded, .blocked] ) } @@ -14,6 +14,7 @@ final class AppReadinessStoreTests: XCTestCase { XCTAssertEqual(AppReadinessStateKind.allCases.map(\.title), [ "Idle", "Preparing app runtime", + "Checking version", "Checking helper", "Validating bundled files", "Ready", @@ -50,6 +51,104 @@ final class AppReadinessStoreTests: XCTestCase { XCTAssertEqual(summary.validationCounts["pass"], 1) } + func testReadinessVersionCheckRunsBeforeCapabilitiesWhenConfigured() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: false)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = makeStore( + runner: runner, + versionCheck: AppReadinessVersionCheck(url: "https://example.invalid/version.json") + ) + + store.start() + + try await waitUntilStoreState { store.state.kind == .ready } + XCTAssertEqual(runner.calls.map(\.operation), ["version-check", "capabilities", "validate-install"]) + XCTAssertEqual(runner.calls.first?.params["url"], .string("https://example.invalid/version.json")) + XCTAssertNil(runner.calls.first?.params["local_version_code"]) + } + + func testBlockingVersionCheckStopsReadinessBeforeCapabilities() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: true)) + ]) + ]) + let store = makeStore( + runner: runner, + versionCheck: AppReadinessVersionCheck(url: "https://example.invalid/version.json") + ) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + XCTAssertEqual(runner.calls.map(\.operation), ["version-check"]) + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .unsupportedVersion) + XCTAssertEqual(issue.message, "Please update.") + XCTAssertEqual(issue.recovery, "Download the latest version from https://example.invalid/download.") + } + + func testUnavailableVersionMetadataDegradesButFailsOpenToReadinessChecks() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: false, source: "unavailable")) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = makeStore( + runner: runner, + versionCheck: AppReadinessVersionCheck(url: "") + ) + + store.start() + + try await waitUntilStoreState { store.state.kind == .degraded } + XCTAssertEqual(runner.calls.map(\.operation), ["version-check", "capabilities", "validate-install"]) + XCTAssertEqual(runner.calls.first?.params, [:]) + XCTAssertEqual(store.versionCheckPayload?.source, "unavailable") + XCTAssertTrue(store.issues.contains(where: { $0.code == .versionMetadataUnavailable && $0.severity == .warning })) + } + + func testVersionCheckErrorDegradesButContinuesReadiness() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "version-check", code: "network_failed", message: "offline") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = makeStore( + runner: runner, + versionCheck: AppReadinessVersionCheck(url: "https://example.invalid/version.json") + ) + + store.start() + + try await waitUntilStoreState { store.state.kind == .degraded } + XCTAssertEqual(runner.calls.map(\.operation), ["version-check", "capabilities", "validate-install"]) + XCTAssertTrue(store.issues.contains(where: { $0.code == .versionMetadataUnavailable && $0.message == "offline" })) + } + func testValidationFailureBlocksApp() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ @@ -223,6 +322,7 @@ final class AppReadinessStoreTests: XCTestCase { XCTAssertEqual(store.state.kind, .idle) XCTAssertNil(store.capabilities) XCTAssertNil(store.validation) + XCTAssertNil(store.versionCheckPayload) XCTAssertEqual(store.issues, []) XCTAssertNil(store.currentStage) } @@ -230,15 +330,18 @@ final class AppReadinessStoreTests: XCTestCase { private func makeStore( runner: StoreTestRunner, issues: [BundleRuntimeIssue] = [], - resolveError: Error? = nil + resolveError: Error? = nil, + versionCheck: AppReadinessVersionCheck? = nil ) -> AppReadinessStore { let backend = BackendClient(runner: runner) let resolver = TestRuntimeResolver(issues: issues, resolveError: resolveError) - return AppReadinessStore( + let store = AppReadinessStore( backend: backend, runtimeResolver: resolver, helperPathProvider: { "" } ) + store.applyVersionCheck(versionCheck) + return store } private func capabilitiesPayload() -> JSONValue { @@ -274,6 +377,22 @@ final class AppReadinessStoreTests: XCTestCase { "summary": .string(ok ? "install validation passed." : "install validation failed.") ]) } + + private func versionCheckPayload(shouldBlock: Bool, source: String = "network") -> JSONValue { + .object([ + "schema_version": .number(1), + "should_block": .bool(shouldBlock), + "checked_url": .string("https://example.invalid/version.json"), + "message": .string("Please update."), + "download_url": .string("https://example.invalid/download"), + "local_version_code": .number(20000), + "current_version": .number(20125), + "min_supported_version": .number(20125), + "latest_tag": .string("v2.1.4"), + "source": .string(source), + "summary": .string(shouldBlock ? "update required." : "TimeCapsuleSMB is up to date.") + ]) + } } private struct TestRuntimeResolver: AppRuntimeResolving { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppUpdateStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppUpdateStoreTests.swift new file mode 100644 index 00000000..59534224 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppUpdateStoreTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AppUpdateStoreTests: XCTestCase { + func testCheckNowMarksCurrentVersion() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: false)) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = AppUpdateStore(coordinator: coordinator) + var settings = AppSettings.default + settings.versionCheckURL = "https://example.invalid/version.json" + + store.checkNow(settings: settings) + + try await waitUntilStoreState { store.state == .current } + XCTAssertEqual(runner.calls.map(\.operation), ["version-check"]) + XCTAssertEqual(runner.calls.first?.params["url"], .string("https://example.invalid/version.json")) + XCTAssertEqual(store.payload?.source, "network") + } + + func testCheckNowSurfacesUnavailableMetadataSeparatelyFromCurrentVersion() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: false, source: "unavailable")) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = AppUpdateStore(coordinator: coordinator) + + store.checkNow(settings: .default) + + try await waitUntilStoreState { store.state == .unavailable } + XCTAssertEqual(store.payload?.summary, "version metadata is unavailable.") + } + + func testCheckNowBlocksConcurrentUpdateChecks() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [], delayNanoseconds: 250_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = AppUpdateStore(coordinator: coordinator) + + store.checkNow(settings: .default) + store.checkNow(settings: .default) + + XCTAssertEqual(store.state, .failed) + XCTAssertEqual(store.error?.code, "operation_rejected") + } + + private func versionCheckPayload(shouldBlock: Bool, source: String = "network") -> JSONValue { + .object([ + "schema_version": .number(1), + "should_block": .bool(shouldBlock), + "checked_url": .string("https://example.invalid/version.json"), + "message": .string(shouldBlock ? "Please update." : "Current."), + "download_url": .string("https://example.invalid/download"), + "local_version_code": .number(20125), + "current_version": .number(20125), + "min_supported_version": .number(20000), + "latest_tag": .string("v2.1.4"), + "source": .string(source), + "summary": .string(source == "unavailable" ? "version metadata is unavailable." : "TimeCapsuleSMB is up to date.") + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift index f3b515a4..89d3b610 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift @@ -13,8 +13,14 @@ final class BundleLayoutTests: XCTestCase { .pythonRuntimeMissing, .pythonExecutableMissing, .distributionRootMissing, + .artifactManifestMissing, + .artifactManifestInvalid, .distributionArtifactsMissing, .toolsDirectoryMissing, + .applicationSupportUnavailable, + .stateDirectoryUnavailable, + .unsupportedVersion, + .versionMetadataUnavailable, .installValidationFailed, .helperLaunchFailed, .contractDecodeFailed, @@ -61,6 +67,46 @@ final class BundleLayoutTests: XCTestCase { XCTAssertTrue(issues.contains(where: { $0.code == .distributionArtifactsMissing && $0.severity == .error })) } + func testMissingArtifactManifestIsBlockingIssue() throws { + let layout = try makeLayout(createArtifactManifest: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .artifactManifestMissing && $0.severity == .error })) + } + + func testInvalidArtifactManifestIsBlockingIssue() throws { + let layout = try makeLayout(artifactManifestContents: "{") + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .artifactManifestInvalid && $0.severity == .error })) + } + + func testUnsafeArtifactManifestPathIsBlockingIssue() throws { + let layout = try makeLayout(artifactManifestContents: """ + { + "artifacts": { + "one": { + "path": "../outside" + } + } + } + """) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .artifactManifestInvalid && $0.severity == .error })) + } + + func testManifestMissingArtifactIsBlockingIssue() throws { + let layout = try makeLayout(createManifestArtifact: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .distributionArtifactsMissing && $0.severity == .error })) + } + func testMissingPythonRuntimeIsBlockingIssue() throws { let layout = try makeLayout(createPythonRuntime: false) @@ -85,23 +131,40 @@ final class BundleLayoutTests: XCTestCase { XCTAssertTrue(issues.contains(where: { $0.code == .toolsDirectoryMissing && $0.severity == .warning })) } + func testApplicationSupportPathMustBeWritableDirectory() throws { + let temp = try TemporaryDirectory() + let appSupportFile = temp.url.appendingPathComponent("Application Support") + try "not a directory".write(to: appSupportFile, atomically: true, encoding: .utf8) + let layout = try makeLayout(applicationSupportURL: appSupportFile) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .applicationSupportUnavailable && $0.severity == .error })) + } + private func makeLayout( createHelper: Bool = true, helperPermissions: Int = 0o755, createDistribution: Bool = true, createDistributionBin: Bool = true, + createArtifactManifest: Bool = true, + artifactManifestContents: String? = nil, + createManifestArtifact: Bool = true, createPythonRuntime: Bool = true, createPythonExecutable: Bool = true, - createTools: Bool = true + createTools: Bool = true, + applicationSupportURL: URL? = nil ) throws -> BundleLayout { let temp = try TemporaryDirectory() let app = temp.url.appendingPathComponent("TimeCapsuleSMB.app", isDirectory: true) let resources = app.appendingPathComponent("Contents/Resources", isDirectory: true) let helpers = app.appendingPathComponent("Contents/Helpers", isDirectory: true) - let appSupport = temp.url.appendingPathComponent("Application Support", isDirectory: true) + let appSupport = applicationSupportURL ?? temp.url.appendingPathComponent("Application Support", isDirectory: true) try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: helpers, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + if applicationSupportURL == nil { + try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + } let helper = helpers.appendingPathComponent("tcapsule") if createHelper { @@ -112,9 +175,30 @@ final class BundleLayoutTests: XCTestCase { let distribution = resources.appendingPathComponent("Distribution", isDirectory: true) try FileManager.default.createDirectory(at: distribution, withIntermediateDirectories: true) if createDistributionBin { - try FileManager.default.createDirectory( - at: distribution.appendingPathComponent("bin", isDirectory: true), - withIntermediateDirectories: true + let artifactDirectory = distribution.appendingPathComponent("bin/payloads", isDirectory: true) + try FileManager.default.createDirectory(at: artifactDirectory, withIntermediateDirectories: true) + if createManifestArtifact { + try "payload".write( + to: artifactDirectory.appendingPathComponent("one"), + atomically: true, + encoding: .utf8 + ) + } + } + if createArtifactManifest { + let manifest = artifactManifestContents ?? """ + { + "artifacts": { + "one": { + "path": "bin/payloads/one" + } + } + } + """ + try manifest.write( + to: distribution.appendingPathComponent("artifact-manifest.json"), + atomically: true, + encoding: .utf8 ) } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DiagnosticsExportBuilderTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DiagnosticsExportBuilderTests.swift new file mode 100644 index 00000000..d1c0b058 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DiagnosticsExportBuilderTests.swift @@ -0,0 +1,142 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class DiagnosticsExportBuilderTests: XCTestCase { + func testExportIncludesReleaseReadinessAndDeviceContext() { + let text = DiagnosticsExportBuilder().build(context: makeContext()) + + XCTAssertTrue(text.contains("TimeCapsuleSMB Diagnostics")) + XCTAssertTrue(text.contains("Generated: 2026-05-26T12:00:00Z")) + XCTAssertTrue(text.contains("- Version: 2.1.4")) + XCTAssertTrue(text.contains("- State: Ready")) + XCTAssertTrue(text.contains("- Helper Version: 2.1.4 (20125)")) + XCTAssertTrue(text.contains("- Validation Counts: checks=1, fail=0, pass=1")) + XCTAssertTrue(text.contains("- Name: Office Capsule")) + XCTAssertTrue(text.contains("- Active device:profile-one: deploy")) + XCTAssertTrue(text.contains("- Pending Confirmation: none")) + } + + func testExportRedactsSecretsInSettingsEventsAndErrors() { + var context = makeContext() + context.events = [ + BackendEvent( + type: "error", + operation: "deploy", + code: "failed", + message: "deploy failed", + payload: .object([ + "credentials": .object(["password": .string("super-secret")]), + "token": .string("abc123"), + "host": .string("10.0.0.2") + ]), + debug: .object([ + "authorization": .string("Bearer abc123"), + "path": .string("/tmp/log") + ]) + ) + ] + + let text = DiagnosticsExportBuilder().build(context: context) + + XCTAssertFalse(text.contains("super-secret")) + XCTAssertFalse(text.contains("abc123")) + XCTAssertTrue(text.contains("")) + XCTAssertTrue(text.contains("10.0.0.2")) + } + + func testExportBoundsBackendEvents() { + var context = makeContext() + context.events = (0..<55).map { + BackendEvent(type: "stage", operation: "doctor", stage: "stage-\($0)") + } + + let text = DiagnosticsExportBuilder(maxEvents: 2).build(context: context) + + XCTAssertFalse(text.contains("stage-52")) + XCTAssertTrue(text.contains("stage-53")) + XCTAssertTrue(text.contains("stage-54")) + } + + private func makeContext() -> DiagnosticsExportContext { + DiagnosticsExportContext( + generatedAt: Date(timeIntervalSince1970: 1_779_796_800), + appVersion: "2.1.4", + appBuild: "20125", + applicationSupportPath: "/Users/test/Library/Application Support/TimeCapsuleSMB", + helperPath: "", + appSettings: .default, + readinessState: .ready, + readinessVersionPayload: versionPayload(), + capabilities: CapabilitiesPayload( + schemaVersion: 1, + apiSchemaVersion: 1, + helperVersion: "2.1.4", + helperVersionCode: 20125, + operations: ["deploy", "doctor"], + distributionRoot: "/Applications/TimeCapsuleSMB.app/Contents/Resources/Distribution", + artifactManifestSHA256: "abc", + confirmationSchemaVersion: 1, + summary: "helper capabilities resolved." + ), + validation: InstallValidationPayload( + schemaVersion: 1, + ok: true, + checks: [InstallCheckPayload(id: "python_modules", ok: true, message: "required Python modules import", details: nil)], + counts: ["checks": 1, "pass": 1, "fail": 0], + summary: "install validation passed." + ), + runtimeIssues: [], + updateState: .current, + updatePayload: versionPayload(), + updateError: nil, + selectedProfile: profile(), + activeOperations: [.device("profile-one"): ActiveOperation(operation: "deploy", profileID: "profile-one", context: nil)], + pendingConfirmation: nil, + events: [BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["summary": .string("doctor passed.")]))] + ) + } + + private func versionPayload(source: String = "network") -> VersionCheckPayload { + VersionCheckPayload( + schemaVersion: 1, + shouldBlock: false, + checkedURL: "https://example.invalid/version.json", + message: "Current.", + downloadURL: "https://example.invalid/download", + localVersionCode: 20125, + currentVersion: 20125, + minSupportedVersion: 20000, + latestTag: "v2.1.4", + source: source, + summary: source == "unavailable" ? "version metadata is unavailable." : "TimeCapsuleSMB is up to date." + ) + } + + private func profile() -> DeviceProfile { + DeviceProfile( + id: "profile-one", + displayName: "Office Capsule", + host: "root@10.0.0.2", + bonjourName: "Office Capsule", + bonjourFullname: "Office Capsule._airport._tcp.local.", + hostname: "office-capsule.local.", + addresses: ["10.0.0.2"], + syap: "119", + model: "TimeCapsule8,119", + osName: "NetBSD", + osRelease: "6.0", + arch: "evbarm", + elfEndianness: "little", + payloadFamily: "netbsd6", + deviceGeneration: "gen5", + configPath: "/tmp/profile-one/.env", + keychainAccount: "profile-one", + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), + lastCheckup: nil, + lastDeploy: nil, + settings: .default, + passwordState: .available + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift index 8864ea86..74854c3a 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift @@ -1,3 +1,4 @@ +import AppKit import XCTest @testable import TimeCapsuleSMBApp @@ -12,25 +13,29 @@ final class FlashWorkflowStoreTests: XCTestCase { .savingBackup, .analyzingBanks, .planAvailable, + .appleCheckComplete, + .appleFirmwareMismatch, + .appleFirmwareReady, .writeLocked, .awaitingStrongConfirmation, .writing, .readbackValidating, .writeValidated, + .writeValidatedSnapshotStale, .manualPowerCycleRequired, .restoreRebooting, .failed ]) } - func testReleaseDefaultDisablesFlashEvenForNetBSD4() throws { + func testDefaultPolicyEnablesFlashWritesForNetBSD4() throws { let profile = try makeProfile(payloadFamily: "netbsd4_samba4") let store = FlashWorkflowStore() store.refresh(profile: profile) - XCTAssertEqual(store.state, .disabledInThisBuild) - XCTAssertTrue(store.eligibilityMessage.contains("disabled")) + XCTAssertEqual(store.state, .writeLocked) + XCTAssertTrue(store.canBackup) } func testReadOnlyPolicyAllowsAnalysisButNotWrites() throws { @@ -61,24 +66,335 @@ final class FlashWorkflowStoreTests: XCTestCase { XCTAssertFalse(FlashBootHookVisibilityPolicy.isVisible(for: netbsd6)) } - func testFlashPresentationExposesAllActionsButEnablesOnlyReadOnlyEntryPoint() { - let readOnlyStates: Set = [ - .eligibleForReadOnlyAnalysis, - .planAvailable, - .writeLocked, - .awaitingStrongConfirmation - ] + func testFlashActionSymbolsResolveToSFSymbols() { + XCTAssertEqual(FlashUserAction.backupAndInspect.systemImage, "externaldrive.badge.questionmark") + for action in [ + FlashUserAction.backupAndInspect, + .planPatch, + .planRestore, + .checkApple, + .downloadApple, + .writePatch, + .writeRestore + ] { + XCTAssertNotNil(NSImage(systemSymbolName: action.systemImage, accessibilityDescription: nil), action.systemImage) + } + } - for state in FlashWorkflowState.allCases { - let presentation = FlashPresentation(state: state, message: "message") + func testBackupAndPlanFlowTracksStructuredPayloads() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "flash", stage: "read_flash", risk: "remote_read", cancellable: true), + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "flash", stage: "plan_flash", risk: "local_write", cancellable: true), + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .patch, writeRequested: true)) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) - XCTAssertEqual(presentation.actions, [.backupAndInspect, .patchBootHook, .restoreFirmware]) - XCTAssertEqual(presentation.message, "message") - XCTAssertEqual(presentation.stateTitle, state.title) - XCTAssertEqual(presentation.isEnabled(.backupAndInspect), readOnlyStates.contains(state), "Unexpected backup action state for \(state).") - XCTAssertFalse(presentation.isEnabled(.patchBootHook), "Patch action must remain disabled for \(state).") - XCTAssertFalse(presentation.isEnabled(.restoreFirmware), "Restore action must remain disabled for \(state).") - } + store.backupAndInspect(password: "pw", profile: profile) + XCTAssertEqual(store.state, .readingBanks) + try await waitUntilStoreState { store.backup != nil && store.state == .planAvailable } + + store.planFlash(mode: .patch, profile: profile) + try await waitUntilStoreState { store.plan != nil && store.canWritePatch } + + XCTAssertEqual(runner.calls.count, 2) + XCTAssertEqual(runner.calls[0].operation, "flash") + XCTAssertEqual(runner.calls[0].params["action"], .string("backup")) + XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(runner.calls[1].params["action"], .string("plan")) + XCTAssertEqual(runner.calls[1].params["backup_dir"], .string("/tmp/flash-backup")) + XCTAssertEqual(runner.calls[1].params["mode"], .string("patch")) + } + + func testPlanFlashCarriesAppleFirmwareSelectionOptions() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .downloadOnly, writeRequested: false)) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.firmwareVersion = " 7.8.1 " + store.firmwareTemplatePath = " /tmp/firmware.basebinary " + + store.planFlash(mode: .downloadOnly, profile: profile) + try await waitUntilStoreState { runner.calls.count == 2 && store.plan != nil } + + XCTAssertEqual(runner.calls[1].params["firmware_version"], .string("7.8.1")) + XCTAssertEqual(runner.calls[1].params["firmware_template"], .string("/tmp/firmware.basebinary")) + } + + func testFirmwareSelectionEditsInvalidateExistingWritePlan() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .patch, writeRequested: true)) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.firmwareVersion = "7.8.1" + store.planFlash(mode: .patch, profile: profile) + try await waitUntilStoreState { store.canWritePatch } + + store.firmwareVersion = "7.8.2" + + XCTAssertNil(store.plan) + XCTAssertFalse(store.canWritePatch) + XCTAssertEqual(store.state, .planAvailable) + } + + func testAppleCheckPresentationShowsMatchDetails() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent( + type: "result", + operation: "flash", + ok: true, + payload: flashPlanPayload( + mode: .checkApple, + writeRequested: false, + alreadySatisfied: true, + appleMatched: true + ) + ) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .checkApple, profile: profile) + try await waitUntilStoreState { store.state == .appleCheckComplete } + + let presentation = FlashPresentation(store: store) + XCTAssertEqual(presentation.message, "Active firmware bank matches Apple stock firmware 7.8.1.") + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Apple Match", value: "yes"))) + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Apple Version", value: "7.8.1"))) + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Apple Payload SHA-256", value: "inner-sha"))) + } + + func testAppleCheckMismatchUsesDedicatedState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent( + type: "result", + operation: "flash", + ok: true, + payload: flashPlanPayload( + mode: .checkApple, + writeRequested: false, + alreadySatisfied: false, + appleMatched: false + ) + ) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .checkApple, profile: profile) + try await waitUntilStoreState { store.state == .appleFirmwareMismatch } + + XCTAssertTrue(FlashPresentation(store: store).rows.contains(PresentationRow(label: "Apple Match", value: "no"))) + } + + func testValidateAppleRestoreFirmwarePresentationShowsPayloadDetails() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent( + type: "result", + operation: "flash", + ok: true, + payload: flashPlanPayload(mode: .downloadOnly, writeRequested: false, includeFirmwarePayload: true) + ) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .downloadOnly, profile: profile) + try await waitUntilStoreState { store.state == .appleFirmwareReady } + + let presentation = FlashPresentation(store: store) + XCTAssertEqual(presentation.message, "Apple restore firmware validated (version 7.8.1, product 116).") + XCTAssertEqual(presentation.title(for: .downloadApple), "Validate Apple Restore Firmware") + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Firmware Payload", value: "/tmp/flash-backup/primary.download_only.basebinary"))) + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Firmware Payload SHA-256", value: "payload-sha"))) + } + + func testWriteConfirmationCancellationRestoresPlanAvailable() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .patch, writeRequested: true)) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "flash", + code: "confirmation_required", + message: "Confirm?", + details: .object([ + "confirmation_id": .string("confirm-1"), + "presentation_id": .string("flash.patch_write"), + "presentation_values": .object(["host": .string("10.0.0.2")]) + ]) + ) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = FlashWorkflowStore(backend: backend) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .patch, profile: profile) + try await waitUntilStoreState { store.plan != nil } + + store.write(mode: .patch, password: "pw", profile: profile) + try await waitUntilStoreState { store.state == .awaitingStrongConfirmation && backend.pendingConfirmation != nil } + + backend.cancelPendingConfirmation() + + try await waitUntilStoreState { store.state == .planAvailable && backend.pendingConfirmation == nil } + } + + func testValidatedPatchWriteShowsManualPowerCycleNotice() async throws { + let store = try await storeAfterValidatedWrite(mode: .patch) + + XCTAssertEqual(store.state, .writeValidatedSnapshotStale) + XCTAssertEqual(store.manualPowerCycleNotice?.mode, .patch) + + store.dismissManualPowerCycleNotice() + + XCTAssertNil(store.manualPowerCycleNotice) + } + + func testValidatedRestoreWriteShowsManualPowerCycleNotice() async throws { + let store = try await storeAfterValidatedWrite(mode: .restore) + + XCTAssertEqual(store.state, .writeValidatedSnapshotStale) + XCTAssertEqual(store.manualPowerCycleNotice?.mode, .restore) + } + + func testValidatedWriteMarksSnapshotStaleAndDisablesPlanning() async throws { + let store = try await storeAfterValidatedWrite(mode: .patch) + let presentation = FlashPresentation(store: store) + + XCTAssertTrue(store.backupSnapshotStale) + XCTAssertNil(store.plan) + XCTAssertTrue(store.canBackup) + XCTAssertFalse(store.canPlan) + XCTAssertFalse(store.canPlanWrites) + XCTAssertFalse(store.canWritePatch) + XCTAssertEqual(presentation.title(for: .backupAndInspect), "Back Up and Inspect Again") + XCTAssertTrue(presentation.warnings.contains("Firmware was written after this backup. Back up and inspect again before planning another flash action.")) + } + + func testFreshBackupClearsStaleSnapshotAfterWrite() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .patch, writeRequested: true)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashWritePayload(mode: .patch)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .patch, profile: profile) + try await waitUntilStoreState { store.plan != nil } + store.write(mode: .patch, password: "pw", profile: profile) + try await waitUntilStoreState { store.backupSnapshotStale } + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { !store.backupSnapshotStale && store.state == .planAvailable } + + XCTAssertTrue(store.canPlan) + XCTAssertEqual(FlashPresentation(store: store).title(for: .backupAndInspect), "Back Up and Inspect") + } + + func testFlashPresentationUsesWriteResultSummaryAfterWrite() async throws { + let store = try await storeAfterValidatedWrite(mode: .patch) + + let presentation = FlashPresentation(store: store) + + XCTAssertEqual(presentation.message, "flash patch write validated; manual power cycle required.") + } + + private func storeAfterValidatedWrite(mode: FlashPlanMode) async throws -> FlashWorkflowStore { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: mode, writeRequested: true)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashWritePayload(mode: mode)) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: mode, profile: profile) + try await waitUntilStoreState { store.plan != nil } + store.write(mode: mode, password: "pw", profile: profile) + try await waitUntilStoreState { store.manualPowerCycleNotice != nil } + return store } private func makeProfile(payloadFamily: String) throws -> DeviceProfile { @@ -89,4 +405,112 @@ final class FlashWorkflowStoreTests: XCTestCase { applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) ) } + + private func flashBackupPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "backup_dir": .string("/tmp/flash-backup"), + "host": .string("10.0.0.2"), + "syap": .string("116"), + "active_bank": .string("primary"), + "banks": .array([ + .object([ + "name": .string("primary"), + "device": .string("/dev/rflash0.raw"), + "size": .number(128), + "sha256": .string("abc"), + "backup_valid": .bool(true), + "active_candidate": .bool(true), + "would_write": .bool(false), + "write_decision": .string("no write") + ]) + ]), + "counts": .object(["banks": .number(1)]), + "summary": .string("flash backup saved.") + ]) + } + + private func flashPlanPayload( + mode: FlashPlanMode, + writeRequested: Bool, + alreadySatisfied: Bool = false, + appleMatched: Bool? = nil, + includeFirmwarePayload: Bool = false + ) -> JSONValue { + var payload: [String: JSONValue] = [ + "schema_version": .number(1), + "backup_dir": .string("/tmp/flash-backup"), + "mode": .string(mode.rawValue), + "write_requested": .bool(writeRequested), + "already_satisfied": .bool(alreadySatisfied), + "active_bank": .string("primary"), + "banks": .array([]), + "flash_plan": .object(["mode": .string(mode.rawValue)]), + "summary": .string(summary(for: mode, alreadySatisfied: alreadySatisfied)) + ] + if let appleMatched { + payload["apple_firmware_match"] = .object([ + "matched": .bool(appleMatched), + "template_source": .string("catalog"), + "template_product_id": .string("116"), + "template_version": .string("7.8.1"), + "template_sha256": .string("template-sha"), + "inner_sha256": .string("inner-sha"), + "inner_size": .number(123), + "key_id": .string("key-one"), + "inner_model": .number(116), + "inner_version": .string("0x00070801") + ]) + } + if includeFirmwarePayload { + payload["firmware_payload"] = .object([ + "template_source": .string("catalog"), + "template_path": .string("/tmp/firmware.basebinary"), + "template_product_id": .string("116"), + "template_version": .string("7.8.1"), + "template_sha256": .string("template-sha"), + "payload_sha256": .string("payload-sha"), + "payload_size": .number(456), + "expected_prefix_sha256": .string("prefix-sha"), + "expected_prefix_size": .number(123), + "key_id": .string("key-one"), + "inner_model": .number(116), + "inner_version": .string("0x00070801"), + "inner_payload_size": .number(123) + ]) + payload["firmware_payload_path"] = .string("/tmp/flash-backup/primary.download_only.basebinary") + } + return .object(payload) + } + + private func summary(for mode: FlashPlanMode, alreadySatisfied: Bool) -> String { + switch mode { + case .checkApple: + return alreadySatisfied + ? "Active firmware bank matches Apple stock firmware 7.8.1." + : "Active firmware bank does not match Apple stock firmware 7.8.1." + case .downloadOnly: + return "Apple restore firmware validated (version 7.8.1, product 116)." + case .patch, .restore: + return "flash plan generated." + } + } + + private func flashWritePayload(mode: FlashPlanMode) -> JSONValue { + .object([ + "schema_version": .number(1), + "backup_dir": .string("/tmp/flash-backup"), + "mode": .string(mode.rawValue), + "write_status": .string("validated"), + "write_validated": .bool(true), + "write_outcome": .object([ + "status": .string("validated"), + "mode": .string(mode.rawValue), + "write_validated": .bool(true), + "write_may_have_modified_device": .bool(true) + ]), + "summary": .string("flash \(mode.rawValue) write validated; manual power cycle required.") + ]) + } + } diff --git a/macos/TimeCapsuleSMB/tools/package_app.py b/macos/TimeCapsuleSMB/tools/package_app.py index 0a2ead45..a787117b 100755 --- a/macos/TimeCapsuleSMB/tools/package_app.py +++ b/macos/TimeCapsuleSMB/tools/package_app.py @@ -14,8 +14,15 @@ PACKAGE_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = PACKAGE_ROOT.parents[1] +SRC_ROOT = REPO_ROOT / "src" +sys.path.insert(0, str(SRC_ROOT)) + +from timecapsulesmb.core.release import CLI_VERSION, CLI_VERSION_CODE # noqa: E402 + APP_NAME = "TimeCapsuleSMB" PRODUCT_NAME = "TimeCapsuleSMB" +APP_VERSION = CLI_VERSION +APP_VERSION_CODE = str(CLI_VERSION_CODE) ARTIFACT_MANIFEST = REPO_ROOT / "src" / "timecapsulesmb" / "assets" / "artifact-manifest.json" @@ -57,8 +64,8 @@ def write_info_plist(contents_dir: Path) -> None: "CFBundleIdentifier": "com.timecapsulesmb.TimeCapsuleSMB", "CFBundleName": APP_NAME, "CFBundlePackageType": "APPL", - "CFBundleShortVersionString": "0.1.0", - "CFBundleVersion": "1", + "CFBundleShortVersionString": APP_VERSION, + "CFBundleVersion": APP_VERSION_CODE, "LSMinimumSystemVersion": "14.0", "NSHighResolutionCapable": True, } @@ -119,6 +126,7 @@ def copy_distribution(resources_dir: Path) -> None: shutil.rmtree(distribution) distribution.mkdir(parents=True) shutil.copytree(REPO_ROOT / "bin", distribution / "bin") + shutil.copy2(ARTIFACT_MANIFEST, distribution / "artifact-manifest.json") assert_distribution_artifacts(distribution) @@ -199,6 +207,7 @@ def assert_bundle_layout(app: Path) -> None: helper = app / "Contents" / "Helpers" / "tcapsule" python = app / "Contents" / "Resources" / "Python" / "bin" / "python" distribution = app / "Contents" / "Resources" / "Distribution" + artifact_manifest = distribution / "artifact-manifest.json" tools_bin = app / "Contents" / "Resources" / "Tools" / "bin" required_executables = [helper, python] missing_executables = [path for path in required_executables if not path.is_file() or not os.access(path, os.X_OK)] @@ -207,6 +216,8 @@ def assert_bundle_layout(app: Path) -> None: raise RuntimeError(f"App bundle is missing required executable(s):\n - {joined}") if not (distribution / "bin").is_dir(): raise RuntimeError(f"App bundle is missing bundled payload directory: {distribution / 'bin'}") + if not artifact_manifest.is_file(): + raise RuntimeError(f"App bundle is missing bundled artifact manifest: {artifact_manifest}") if not tools_bin.is_dir(): raise RuntimeError(f"App bundle is missing bundled tools directory: {tools_bin}") assert_distribution_artifacts(distribution) diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index 7f9ed624..f146a8b8 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -302,6 +302,117 @@ def repair_xattrs_payload(raw: Mapping[str, object]) -> dict[str, object]: return _with_schema(payload) +def flash_backup_payload(raw: Mapping[str, object]) -> dict[str, object]: + banks = raw.get("banks") + bank_count = len(banks) if isinstance(banks, list) else 0 + return _with_schema({ + **raw, + "counts": {"banks": bank_count}, + "summary": f"flash backup saved to {raw.get('backup_dir')}.", + }) + + +def _flash_plan_dict(raw: Mapping[str, object]) -> dict[str, object]: + plan = raw.get("flash_plan") + return plan if isinstance(plan, dict) else {} + + +def _flash_plan_child(plan: Mapping[str, object], key: str) -> dict[str, object] | None: + value = plan.get(key) + return dict(value) if isinstance(value, dict) else None + + +def _firmware_payload_path(raw: Mapping[str, object], plan: Mapping[str, object]) -> str | None: + target_bank = plan.get("target_bank") + mode = plan.get("mode") + if not isinstance(target_bank, str) or not isinstance(mode, str): + return None + files = raw.get("files") + if not isinstance(files, dict): + return None + value = files.get(f"{target_bank}_{mode}_basebinary_payload") + return value if isinstance(value, str) and value.strip() else None + + +def _apple_firmware_summary(mode: str, match: Mapping[str, object] | None, payload: Mapping[str, object] | None) -> str | None: + if mode == "check_apple": + version = None if match is None else match.get("template_version") + version_text = f" {version}" if isinstance(version, str) and version.strip() else "" + if match is not None and match.get("matched") is True: + return f"Active firmware bank matches Apple stock firmware{version_text}." + return f"Active firmware bank does not match Apple stock firmware{version_text}." + if mode == "download_only": + version = None if payload is None else payload.get("template_version") + product = None if payload is None else payload.get("template_product_id") + detail_parts = [] + if isinstance(version, str) and version.strip(): + detail_parts.append(f"version {version}") + if isinstance(product, str) and product.strip(): + detail_parts.append(f"product {product}") + detail = f" ({', '.join(detail_parts)})" if detail_parts else "" + return f"Apple restore firmware validated{detail}." + return None + + +def flash_plan_payload(raw: Mapping[str, object]) -> dict[str, object]: + plan = _flash_plan_dict(raw) + mode = "unknown" + write_requested = False + already_satisfied = False + if plan: + mode = str(plan.get("mode") or mode) + write_requested = bool(plan.get("write_requested")) + already_satisfied = bool(plan.get("already_satisfied")) + apple_firmware_match = _flash_plan_child(plan, "apple_match") + firmware_payload = _flash_plan_child(plan, "payload") + firmware_payload_path = _firmware_payload_path(raw, plan) + apple_summary = _apple_firmware_summary(mode, apple_firmware_match, firmware_payload) + if apple_summary is not None: + summary = apple_summary + elif already_satisfied: + summary = "flash plan is already satisfied; no write is needed." + elif write_requested: + summary = f"flash {mode} write plan generated." + else: + summary = f"flash {mode} plan generated." + return _with_schema({ + **raw, + "mode": mode, + "write_requested": write_requested, + "already_satisfied": already_satisfied, + "apple_firmware_match": apple_firmware_match, + "firmware_payload": firmware_payload, + "firmware_payload_path": firmware_payload_path, + "summary": summary, + }) + + +def flash_write_payload(raw: Mapping[str, object]) -> dict[str, object]: + outcome = raw.get("write_outcome") + status = "unknown" + mode = "unknown" + write_validated = False + if isinstance(outcome, dict): + status = str(outcome.get("status") or status) + mode = str(outcome.get("mode") or mode) + write_validated = bool(outcome.get("write_validated")) + if status == "not_needed": + summary = "flash write was not needed." + elif write_validated and mode == "patch": + summary = "flash patch write validated; manual power cycle required." + elif write_validated: + summary = f"flash {mode} write validated; manual power cycle required." + else: + summary = "flash write completed." + return _with_schema({ + **raw, + "mode": mode, + "write_status": status, + "write_validated": write_validated, + "summary": summary, + }) + + def doctor_payload( *, fatal: bool, diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py index d8b88cc5..a1366dac 100644 --- a/src/timecapsulesmb/app/ops/__init__.py +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -6,6 +6,7 @@ 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.flash import flash_operation from timecapsulesmb.app.ops.maintenance import ( activate_operation, fsck_operation, @@ -31,6 +32,7 @@ "deploy": deploy_operation, "discover": discover_operation, "doctor": doctor_operation, + "flash": flash_operation, "fsck": fsck_operation, "paths": paths_operation, "repair-xattrs": repair_xattrs_operation, @@ -47,6 +49,7 @@ "configure", "deploy", "doctor", + "flash", "fsck", "repair-xattrs", "uninstall", diff --git a/src/timecapsulesmb/app/ops/flash.py b/src/timecapsulesmb/app/ops/flash.py new file mode 100644 index 00000000..d12da13b --- /dev/null +++ b/src/timecapsulesmb/app/ops/flash.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from pathlib import Path + +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation +from timecapsulesmb.app.contracts import flash_backup_payload, flash_plan_payload, flash_write_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.device.compat import is_netbsd4_payload_family, payload_family_description +from timecapsulesmb.device.errors import DeviceError +from timecapsulesmb.flash import FlashAnalysisError +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + required_path_param, + string_param, +) +from timecapsulesmb.services.credentials import overlay_request_credentials +from timecapsulesmb.services.flash import ( + WRITE_OPERATIONS, + FlashTarget, + backup_flash, + flash_target_from_connection, + plan_flash_from_backup, + record_write_outcome, + validate_live_target_matches_backup, + write_flash_plan, +) +from timecapsulesmb.services.runtime import ( + load_env_config, + require_connection_compatibility, + resolve_validated_managed_target, +) +from timecapsulesmb.transport.errors import TransportError + + +FLASH_ACTIONS = {"backup", "plan", "write"} +PLAN_OPERATIONS = {"patch", "restore", "check_apple", "download_only"} + + +def flash_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "flash" + action = string_param(params, "action", "backup").strip() or "backup" + if action not in FLASH_ACTIONS: + raise AppOperationError(f"unsupported flash action: {action}", code="validation_failed") + if action == "backup": + return _backup_operation(params, sink) + if action == "plan": + return _plan_operation(params, sink) + return _write_operation(params, sink) + + +def _optional_path_param(params: dict[str, object], name: str) -> Path | None: + value = params.get(name) + if value in (None, ""): + return None + return Path(str(value)).expanduser() + + +def _firmware_template_param(params: dict[str, object]) -> Path | None: + return _optional_path_param(params, "firmware_template") + + +def _firmware_version_param(params: dict[str, object]) -> str | None: + value = string_param(params, "firmware_version").strip() + return value or None + + +def _plan_operation_param(params: dict[str, object]) -> str: + plan_operation = string_param(params, "mode", "patch").strip() or "patch" + if plan_operation not in PLAN_OPERATIONS: + raise AppOperationError(f"unsupported flash plan mode: {plan_operation}", code="validation_failed") + return plan_operation + + +def _write_operation_param(params: dict[str, object]) -> str: + plan_operation = _plan_operation_param(params) + if plan_operation not in WRITE_OPERATIONS: + raise AppOperationError(f"flash mode {plan_operation} does not write firmware", code="validation_failed") + return plan_operation + + +def _load_flash_config(params: dict[str, object], sink: EventSink) -> AppConfig: + sink.stage("flash", "load_config") + return overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + + +def _resolve_flash_target(config: AppConfig, sink: EventSink) -> FlashTarget: + sink.stage("flash", "resolve_connection") + target = resolve_validated_managed_target( + config, + command_name="flash", + profile="flash", + include_probe=False, + ) + sink.stage("flash", "check_compatibility") + try: + compatibility = require_connection_compatibility(target.connection) + except DeviceError as exc: + raise AppOperationError(str(exc), code="unsupported_device") from exc + if not is_netbsd4_payload_family(compatibility.payload_family): + raise AppOperationError( + "flash is only supported for NetBSD4 AirPort storage devices.", + code="unsupported_device", + ) + sink.log("flash", f"Using {payload_family_description(compatibility.payload_family)} payload family for flash work.") + return flash_target_from_connection(target.connection, compatibility) + + +def _backup_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + config = _load_flash_config(params, sink) + target = _resolve_flash_target(config, sink) + backup_dir = _optional_path_param(params, "backup_dir") + try: + bundle = backup_flash( + target=target, + backup_dir=backup_dir, + operation="read_only", + log=lambda message: sink.log("flash", message), + stage=lambda stage: sink.stage("flash", stage), + ) + except FlashAnalysisError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + except TransportError as exc: + raise AppOperationError(f"SSH flash read failed: {exc}", code="remote_error") from exc + return OperationResult(True, flash_backup_payload(bundle.manifest)) + + +def _plan_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + plan_operation = _plan_operation_param(params) + force = bool_param(params, "force") + backup_dir = required_path_param(params, "backup_dir") + firmware_template = _firmware_template_param(params) + firmware_version = _firmware_version_param(params) + try: + sink.stage("flash", "inspect_backup") + sink.stage("flash", "plan_flash") + bundle, _plan = plan_flash_from_backup( + backup_dir=backup_dir, + operation=plan_operation, + force=force, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + except FlashAnalysisError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + return OperationResult(True, flash_plan_payload(bundle.manifest)) + + +def _confirmation_message(target: FlashTarget, mode: str, bank: str | None) -> str: + if mode == "patch": + return ( + f"Patch the primary firmware bank boot hook on {target.acp_host} " + "and acknowledge that manual power cycle is required after a successful write?" + ) + bank_text = f" {bank}" if bank else "" + return f"Restore Apple stock firmware to the active{bank_text} bank on {target.acp_host}?" + + +def _write_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + plan_operation = _write_operation_param(params) + force = bool_param(params, "force") + backup_dir = required_path_param(params, "backup_dir") + firmware_template = _firmware_template_param(params) + firmware_version = _firmware_version_param(params) + + try: + sink.stage("flash", "inspect_backup") + sink.stage("flash", "plan_flash") + bundle, plan = plan_flash_from_backup( + backup_dir=backup_dir, + operation=plan_operation, + force=force, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + except FlashAnalysisError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + if plan is None: + raise AppOperationError("flash write has no plan", code="validation_failed") + if plan.already_satisfied: + record_write_outcome( + bundle=bundle, + plan=plan, + status="not_needed", + write_validated=False, + write_may_have_modified_device=False, + ) + return OperationResult(True, flash_write_payload(bundle.manifest)) + + config = _load_flash_config(params, sink) + target = _resolve_flash_target(config, sink) + bank = None if plan.target_bank is None else plan.target_bank.name + sink.stage("flash", "confirm_write") + require_confirmation( + params, + build_confirmation( + operation="flash", + params=params, + title="Confirm firmware flash write", + message=_confirmation_message(target, plan_operation, bank), + action_title="Write Firmware", + risk="destructive", + summary=f"Flash {plan_operation} firmware write", + context={ + "host": target.acp_host, + "backup_dir": str(bundle.backup_dir), + "mode": plan_operation, + "target_bank": bank, + "target_sha256": None if plan.target_bank is None else plan.target_bank.sha256, + }, + presentation_id=f"flash.{plan_operation}_write", + presentation_values={ + "host": target.acp_host, + "backup_dir": str(bundle.backup_dir), + "mode": plan_operation, + "target_bank": bank, + }, + ), + legacy_names=("confirm_flash",), + ) + + try: + sink.stage("flash", "pre_write_validation") + validate_live_target_matches_backup( + connection=target.connection, + plan=plan, + log=lambda message: sink.log("flash", message), + ) + sink.stage("flash", "write_primary_bank" if plan_operation == "patch" else "write_active_bank") + sink.log("flash", "Sending ACP flash command...") + write_flash_plan( + target=target, + bundle=bundle, + plan=plan, + log=lambda message: sink.log("flash", message), + ) + sink.stage("flash", "post_write_validation") + except FlashAnalysisError as exc: + raise AppOperationError(str(exc), code="operation_failed") from exc + except TransportError as exc: + raise AppOperationError(f"SSH post-write validation failed: {exc}", code="remote_error") from exc + return OperationResult(True, flash_write_payload(bundle.manifest)) diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py index 060d3a19..a7625466 100644 --- a/src/timecapsulesmb/app/ops/readiness.py +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -105,6 +105,7 @@ def capabilities_operation(params: dict[str, object], sink: EventSink) -> Operat "deploy", "discover", "doctor", + "flash", "fsck", "paths", "repair-xattrs", diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py index 2a06ac2e..c5a49918 100644 --- a/src/timecapsulesmb/app/stage_policy.py +++ b/src/timecapsulesmb/app/stage_policy.py @@ -112,6 +112,20 @@ def to_jsonable(self) -> dict[str, object]: ("repair-xattrs", "report_findings"): StagePolicy(LOCAL_READ, True, "Render xattr findings and repair candidates."), ("repair-xattrs", "confirm_repair"): StagePolicy(LOCAL_READ, True, "Confirm local metadata repairs."), ("repair-xattrs", "repair_findings"): StagePolicy(DESTRUCTIVE, False, "Repair local file metadata on the mounted SMB share."), + ("flash", "load_config"): StagePolicy(LOCAL_READ, True, "Read flash configuration."), + ("flash", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("flash", "check_compatibility"): StagePolicy(REMOTE_READ, True, "Check NetBSD4 flash compatibility."), + ("flash", "read_flash"): StagePolicy(REMOTE_READ, True, "Read both firmware banks from the device."), + ("flash", "save_raw_backup"): StagePolicy(LOCAL_WRITE, False, "Save raw firmware bank backups locally."), + ("flash", "inspect_backup"): StagePolicy(LOCAL_READ, True, "Read and inspect the saved flash backup."), + ("flash", "analyze_flash"): StagePolicy(LOCAL_READ, True, "Analyze firmware bank safety metadata."), + ("flash", "plan_flash"): StagePolicy(LOCAL_WRITE, True, "Build and save the firmware flash plan."), + ("flash", "save_backup"): StagePolicy(LOCAL_WRITE, False, "Write flash backup manifest."), + ("flash", "confirm_write"): StagePolicy(DESTRUCTIVE, True, "Confirm firmware flash write."), + ("flash", "pre_write_validation"): StagePolicy(REMOTE_READ, True, "Verify the live target bank still matches the saved backup."), + ("flash", "write_primary_bank"): StagePolicy(DESTRUCTIVE, False, "Write the primary firmware bank."), + ("flash", "write_active_bank"): StagePolicy(DESTRUCTIVE, False, "Write the active firmware bank."), + ("flash", "post_write_validation"): StagePolicy(REMOTE_READ, True, "Read back and validate the written firmware bank."), } diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 90055266..bc73adf7 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -566,7 +566,6 @@ class ConfigProfile: ) FLASH_REQUIRED_FILE_KEYS = ( "TC_HOST", - "TC_PASSWORD", ) FLASH_VALIDATED_KEYS = ( "TC_HOST", @@ -608,6 +607,7 @@ class ConfigProfile: ), "flash": ConfigProfile( required_file_values=FLASH_REQUIRED_FILE_KEYS, + required_values=("TC_PASSWORD",), validated_keys=FLASH_VALIDATED_KEYS, ), "repair_xattrs": ConfigProfile( diff --git a/src/timecapsulesmb/services/flash.py b/src/timecapsulesmb/services/flash.py new file mode 100644 index 00000000..787b0c45 --- /dev/null +++ b/src/timecapsulesmb/services/flash.py @@ -0,0 +1,643 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import partial +import json +import re +from pathlib import Path +from typing import Callable + +from timecapsulesmb.apple_firmware import normalize_syap +from timecapsulesmb.core.config import AIRPORT_IDENTITIES_BY_SYAP +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import default_user_data_dir +from timecapsulesmb.device.compat import DeviceCompatibility +from timecapsulesmb.flash import ( + FlashAnalysis, + FlashAnalysisError, + FlashInspection, + inspection_error_message, + inspection_to_jsonable, + inspect_flash_banks, + require_zopfli_gzip_available, + sha256_hex, +) +from timecapsulesmb.flash_workflow import ( + FlashPlan, + plan_check_apple, + plan_download_only, + plan_patch_primary, + plan_restore_apple, + write_and_validate_plan, +) +from timecapsulesmb.integrations.acp import ACPError, flash_firmware_bank, get_property_int +from timecapsulesmb.transport.ssh import SshConnection, run_ssh_capture_bytes + + +FLASH_READ_TIMEOUT_SECONDS = 180 +FLASH_WRITE_TIMEOUT_SECONDS = 300 +WRITE_OPERATIONS = {"patch", "restore"} +READ_OPERATIONS = {"read_only", "patch", "restore", "check_apple", "download_only"} +POWERCYCLE_REQUIRED_MESSAGE = ( + "POWER-CYCLE REQUIRED: unplug the Time Capsule, wait 10 seconds, then plug it back in." +) +STALE_BACKUP_AFTER_WRITE_MESSAGE = ( + "This flash backup was used for a firmware write. Back up and inspect again before planning another flash action." +) + + +@dataclass(frozen=True) +class FlashTarget: + connection: SshConnection + acp_host: str + compatibility: DeviceCompatibility + + +@dataclass(frozen=True) +class FlashInputs: + primary: bytes + secondary: bytes + cks1: int | None + cks2: int | None + syap: str + live_login: bytes + + +@dataclass(frozen=True) +class FlashAnalysisBundle: + inspection: FlashInspection + analysis: FlashAnalysis | None + backup_dir: Path + manifest: dict[str, object] + + +def _emit(log: object | None, message: str) -> None: + if log is None: + return + log(message) # type: ignore[misc] + + +def _safe_path_part(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()) + return safe.strip("-.") or "device" + + +def default_flash_backup_root() -> Path: + return default_user_data_dir() / "flash-backups" + + +def build_flash_backup_dir(*, base_dir: Path | None, host: str, syap: str) -> Path: + if base_dir is not None: + return base_dir.expanduser().resolve() + root = default_flash_backup_root() + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%fZ") + return root / f"{timestamp}-{_safe_path_part(host)}-syAP{_safe_path_part(syap)}" + + +def flash_target_from_connection(connection: SshConnection, compatibility: DeviceCompatibility) -> FlashTarget: + return FlashTarget( + connection=connection, + acp_host=extract_host(connection.host), + compatibility=compatibility, + ) + + +def dump_remote_bank(connection: SshConnection, device: str, *, log: object | None = None) -> bytes: + _emit(log, f"SSH: /bin/dd if={device} bs=65536 2>/dev/null") + return run_ssh_capture_bytes( + connection, + f"/bin/dd if={device} bs=65536 2>/dev/null", + timeout=FLASH_READ_TIMEOUT_SECONDS, + ) + + +def read_live_login(connection: SshConnection, *, log: object | None = None) -> bytes: + _emit(log, "SSH: /bin/dd if=/etc/rc.d/LOGIN bs=4096 2>/dev/null") + return run_ssh_capture_bytes(connection, "/bin/dd if=/etc/rc.d/LOGIN bs=4096 2>/dev/null", timeout=30) + + +def read_acp_property_int(acp_host: str, password: str, name: str) -> int: + try: + return get_property_int(acp_host, password, name) + except ACPError as exc: + raise FlashAnalysisError(f"ACP property {name} read failed: {exc}") from exc + + +def read_flash_inputs( + connection: SshConnection, + *, + acp_host: str, + password: str, + log: object | None = None, +) -> FlashInputs: + _emit(log, "Reading primary firmware bank from /dev/rflash0.raw...") + primary = dump_remote_bank(connection, "/dev/rflash0.raw", log=log) + _emit(log, "Reading secondary firmware bank from /dev/rflash1.raw...") + secondary = dump_remote_bank(connection, "/dev/rflash1.raw", log=log) + _emit(log, "Reading ACP checksum properties cks1 and cks2...") + cks1 = read_acp_property_int(acp_host, password, "cks1") + cks2 = read_acp_property_int(acp_host, password, "cks2") + _emit(log, "Reading ACP product property syAP...") + syap = normalize_syap(read_acp_property_int(acp_host, password, "syAP")) + _emit(log, "Reading live /etc/rc.d/LOGIN...") + live_login = read_live_login(connection, log=log) + return FlashInputs(primary=primary, secondary=secondary, cks1=cks1, cks2=cks2, syap=syap, live_login=live_login) + + +def dump_remote_bank_for_validation(connection: SshConnection, device: str, *, log: object | None = None) -> bytes: + _emit(log, f"Reading back written firmware bank from {device}...") + return dump_remote_bank(connection, device, log=log) + + +def get_property_int_for_validation( + host: str, + password: str, + name: str, + *, + log: object | None = None, + **kwargs: object, +) -> int: + _emit(log, f"Reading ACP checksum property {name} after write...") + return get_property_int(host, password, name, **kwargs) + + +def _mark_manifest_no_write(manifest: dict[str, object], decision: str) -> None: + banks = manifest.get("banks") + if not isinstance(banks, list): + return + for bank in banks: + if isinstance(bank, dict): + bank["would_write"] = False + bank["write_decision"] = decision + + +def _manifest_banks(manifest: dict[str, object]) -> list[dict[str, object]]: + banks = manifest.get("banks") + if not isinstance(banks, list): + return [] + return [bank for bank in banks if isinstance(bank, dict)] + + +def apply_flash_plan_to_manifest(manifest: dict[str, object], plan: FlashPlan) -> None: + target_name = None if plan.target_bank is None else plan.target_bank.name + for bank in _manifest_banks(manifest): + if bank.get("name") != target_name: + bank["would_write"] = False + if target_name is not None and plan.mode == "patch": + bank["write_decision"] = "secondary backup left unmodified" + elif target_name is not None: + bank["write_decision"] = "inactive bank left unmodified" + continue + + bank["would_write"] = plan.write_requested + if plan.mode == "patch": + if plan.already_satisfied: + bank["write_decision"] = "primary bank already patched; no write needed" + elif plan.write_requested: + bank["write_decision"] = "primary bank patch planned" + elif plan.mode == "restore": + if plan.write_requested: + bank["write_decision"] = "active bank restore from Apple firmware planned" + else: + bank["write_decision"] = "active bank already matches requested Apple stock firmware; no write needed" + elif plan.mode == "check_apple": + bank["write_decision"] = "check only; no firmware write planned" + elif plan.mode == "download_only": + bank["write_decision"] = "download only; no firmware write planned" + + +def manifest_from_inspection( + *, + operation: str, + inspection: FlashInspection, + target: FlashTarget, + inputs: FlashInputs, + backup_dir: Path, +) -> dict[str, object]: + payload = inspection_to_jsonable( + inspection, + write_policy="primary_bank_patch" if operation == "patch" else "active_bank_only", + ) + if operation != "patch": + _mark_manifest_no_write(payload, "backup only; no patch candidate built") + identity = AIRPORT_IDENTITIES_BY_SYAP.get(inputs.syap) + files: dict[str, str] = { + "primary": str(backup_dir / "primary.raw"), + "secondary": str(backup_dir / "secondary.raw"), + "manifest": str(backup_dir / "manifest.json"), + } + payload.update({ + "operation": operation, + "host": target.acp_host, + "syap": inputs.syap, + "device_model": None if identity is None else identity.mdns_model, + "os_release": target.compatibility.os_release, + "backup_dir": str(backup_dir), + "files": files, + "acp_properties": { + "cks1": inputs.cks1, + "cks2": inputs.cks2, + }, + "live_login": { + "size": len(inputs.live_login), + "sha256": sha256_hex(inputs.live_login), + }, + }) + return payload + + +def save_flash_banks(*, backup_dir: Path, primary: bytes, secondary: bytes) -> None: + backup_dir.mkdir(parents=True, exist_ok=True) + (backup_dir / "primary.raw").write_bytes(primary) + (backup_dir / "secondary.raw").write_bytes(secondary) + + +def save_flash_manifest(*, backup_dir: Path, manifest: dict[str, object]) -> None: + backup_dir.mkdir(parents=True, exist_ok=True) + (backup_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + + +def load_flash_manifest(backup_dir: Path) -> dict[str, object]: + manifest_path = backup_dir.expanduser().resolve() / "manifest.json" + try: + data = json.loads(manifest_path.read_text()) + except OSError as exc: + raise FlashAnalysisError(f"flash manifest not found: {manifest_path}") from exc + except json.JSONDecodeError as exc: + raise FlashAnalysisError(f"flash manifest is not valid JSON: {manifest_path}") from exc + if not isinstance(data, dict): + raise FlashAnalysisError(f"flash manifest is not an object: {manifest_path}") + return data + + +def _manifest_file_path(manifest: dict[str, object], backup_dir: Path, name: str) -> Path: + files = manifest.get("files") + if isinstance(files, dict) and isinstance(files.get(name), str): + return Path(str(files[name])).expanduser().resolve() + return backup_dir / f"{name}.raw" + + +def _parse_optional_int(value: object) -> int | None: + if value in (None, ""): + return None + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str): + text = value.strip() + if not text: + return None + return int(text, 16) if text.lower().startswith("0x") else int(text, 10) + return None + + +def _manifest_acp_checksum(manifest: dict[str, object], name: str) -> int | None: + properties = manifest.get("acp_properties") + if isinstance(properties, dict): + value = properties.get("cks1" if name == "primary" else "cks2") + parsed = _parse_optional_int(value) + if parsed is not None: + return parsed + for bank in _manifest_banks(manifest): + if bank.get("name") == name: + return _parse_optional_int(bank.get("acp_checksum")) + return None + + +def _read_backup_raw(backup_dir: Path, manifest: dict[str, object]) -> tuple[bytes, bytes]: + try: + primary = _manifest_file_path(manifest, backup_dir, "primary").read_bytes() + secondary = _manifest_file_path(manifest, backup_dir, "secondary").read_bytes() + except OSError as exc: + raise FlashAnalysisError(f"flash backup raw bank file could not be read: {exc}") from exc + return primary, secondary + + +def _backup_syap(manifest: dict[str, object]) -> str: + syap = str(manifest.get("syap") or "").strip() + if not syap: + raise FlashAnalysisError("flash manifest is missing syAP") + return normalize_syap(syap) + + +def _backup_os_release(manifest: dict[str, object]) -> str: + os_release = str(manifest.get("os_release") or "").strip() + if not os_release: + raise FlashAnalysisError("flash manifest is missing os_release") + return os_release + + +def _backup_was_used_for_write(manifest: dict[str, object]) -> bool: + outcome = manifest.get("write_outcome") + if not isinstance(outcome, dict): + return False + return bool(outcome.get("write_may_have_modified_device")) + + +def require_backup_fresh_for_plan(manifest: dict[str, object]) -> None: + if _backup_was_used_for_write(manifest): + raise FlashAnalysisError(STALE_BACKUP_AFTER_WRITE_MESSAGE) + + +def inspect_backup( + backup_dir: Path, + *, + operation: str, +) -> FlashAnalysisBundle: + if operation not in READ_OPERATIONS: + raise FlashAnalysisError(f"unsupported flash operation: {operation}") + backup_dir = backup_dir.expanduser().resolve() + manifest = load_flash_manifest(backup_dir) + if operation != "read_only": + require_backup_fresh_for_plan(manifest) + primary, secondary = _read_backup_raw(backup_dir, manifest) + inspection = inspect_flash_banks( + primary_data=primary, + secondary_data=secondary, + cks1=_manifest_acp_checksum(manifest, "primary"), + cks2=_manifest_acp_checksum(manifest, "secondary"), + os_release=_backup_os_release(manifest), + build_primary_patch_candidate=operation == "patch", + ) + return FlashAnalysisBundle( + inspection=inspection, + analysis=inspection.strict_analysis, + backup_dir=backup_dir, + manifest=manifest, + ) + + +def backup_flash( + *, + target: FlashTarget, + backup_dir: Path | None, + operation: str = "read_only", + log: object | None = None, + stage: Callable[[str], None] | None = None, +) -> FlashAnalysisBundle: + if stage is not None: + stage("read_flash") + inputs = read_flash_inputs( + target.connection, + acp_host=target.acp_host, + password=target.connection.password, + log=log, + ) + resolved_backup_dir = build_flash_backup_dir(base_dir=backup_dir, host=target.acp_host, syap=inputs.syap) + if stage is not None: + stage("save_raw_backup") + save_flash_banks(backup_dir=resolved_backup_dir, primary=inputs.primary, secondary=inputs.secondary) + if stage is not None: + stage("analyze_flash") + inspection = inspect_flash_banks( + primary_data=inputs.primary, + secondary_data=inputs.secondary, + cks1=inputs.cks1, + cks2=inputs.cks2, + os_release=target.compatibility.os_release, + build_primary_patch_candidate=operation == "patch", + ) + manifest = manifest_from_inspection( + operation=operation, + inspection=inspection, + target=target, + inputs=inputs, + backup_dir=resolved_backup_dir, + ) + if stage is not None: + stage("save_backup") + save_flash_manifest(backup_dir=resolved_backup_dir, manifest=manifest) + return FlashAnalysisBundle( + inspection=inspection, + analysis=inspection.strict_analysis, + backup_dir=resolved_backup_dir, + manifest=manifest, + ) + + +def plan_from_operation( + *, + operation: str, + inspection: FlashInspection, + analysis: FlashAnalysis | None, + force: bool, + syap: str, + firmware_template: Path | None, + firmware_version: str | None, +) -> FlashPlan | None: + if operation == "patch": + return plan_patch_primary( + inspection, + force=force, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + if analysis is None: + raise FlashAnalysisError(inspection_error_message(inspection)) + if operation == "restore": + return plan_restore_apple( + analysis, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + if operation == "check_apple": + return plan_check_apple( + analysis, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + if operation == "download_only": + return plan_download_only( + analysis, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + if operation == "read_only": + return None + raise FlashAnalysisError(f"unsupported flash plan operation: {operation}") + + +def _save_primary_patched_bank_if_ready(*, backup_dir: Path, inspection: FlashInspection) -> Path | None: + primary = inspection.primary.analysis + if primary is None or primary.patch is None: + return None + path = backup_dir / "primary.patched.raw" + path.write_bytes(primary.patch.target_bank) + return path + + +def _save_acp_flash_payload(*, backup_dir: Path, plan: FlashPlan) -> Path | None: + if plan.target_bank is None or plan.payload is None: + return None + suffix = "patched" if plan.mode == "patch" else plan.mode + path = backup_dir / f"{plan.target_bank.name}.{suffix}.basebinary" + path.write_bytes(plan.payload.data) + return path + + +def plan_flash_from_backup( + *, + backup_dir: Path, + operation: str, + force: bool, + firmware_template: Path | None, + firmware_version: str | None, +) -> tuple[FlashAnalysisBundle, FlashPlan | None]: + if operation == "patch": + require_zopfli_gzip_available() + bundle = inspect_backup(backup_dir, operation=operation) + syap = _backup_syap(bundle.manifest) + plan = plan_from_operation( + operation=operation, + inspection=bundle.inspection, + analysis=bundle.analysis, + force=force, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + bundle.manifest["operation"] = operation + bundle.manifest["flash_plan_params"] = { + "operation": operation, + "force": force, + "firmware_template": None if firmware_template is None else str(firmware_template), + "firmware_version": firmware_version, + } + if plan is not None: + if operation == "patch": + patched_primary_path = _save_primary_patched_bank_if_ready( + backup_dir=bundle.backup_dir, + inspection=bundle.inspection, + ) + if patched_primary_path is not None: + files = bundle.manifest.get("files") + if isinstance(files, dict): + files["primary_patched"] = str(patched_primary_path) + payload_path = _save_acp_flash_payload(backup_dir=bundle.backup_dir, plan=plan) + files = bundle.manifest.get("files") + if isinstance(files, dict) and payload_path is not None and plan.target_bank is not None: + files[f"{plan.target_bank.name}_{plan.mode}_basebinary_payload"] = str(payload_path) + bundle.manifest["flash_plan"] = plan.to_jsonable() + apply_flash_plan_to_manifest(bundle.manifest, plan) + save_flash_manifest(backup_dir=bundle.backup_dir, manifest=bundle.manifest) + return bundle, plan + + +def write_outcome_payload( + *, + plan: FlashPlan, + status: str, + write_validated: bool, + write_may_have_modified_device: bool, + stage: str | None = None, + message: str | None = None, +) -> dict[str, object]: + outcome: dict[str, object] = { + "status": status, + "mode": plan.mode, + "write_validated": write_validated, + "write_may_have_modified_device": write_may_have_modified_device, + } + if plan.target_bank is not None: + outcome.update({ + "bank": plan.target_bank.name, + "device": plan.target_bank.device, + }) + if plan.payload is not None: + outcome.update({ + "firmware_payload_sha256": plan.payload.payload_sha256, + "firmware_payload_size": len(plan.payload.data), + "expected_prefix_sha256": plan.payload.expected_prefix_sha256, + "expected_prefix_size": len(plan.payload.expected_prefix), + }) + if stage is not None: + outcome["stage"] = stage + if message is not None: + outcome["message"] = message + return outcome + + +def record_write_outcome( + *, + bundle: FlashAnalysisBundle, + plan: FlashPlan, + status: str, + write_validated: bool, + write_may_have_modified_device: bool, + stage: str | None = None, + message: str | None = None, + write_result: dict[str, object] | None = None, +) -> None: + bundle.manifest["write_outcome"] = write_outcome_payload( + plan=plan, + status=status, + write_validated=write_validated, + write_may_have_modified_device=write_may_have_modified_device, + stage=stage, + message=message, + ) + if write_result is not None: + bundle.manifest["write_result"] = write_result + save_flash_manifest(backup_dir=bundle.backup_dir, manifest=bundle.manifest) + + +def validate_live_target_matches_backup( + *, + connection: SshConnection, + plan: FlashPlan, + log: object | None = None, +) -> None: + if plan.target_bank is None: + raise FlashAnalysisError("flash plan has no target bank") + _emit(log, f"Verifying live {plan.target_bank.name} bank still matches the saved backup...") + live = dump_remote_bank(connection, plan.target_bank.device, log=log) + live_sha256 = sha256_hex(live) + if live_sha256 != plan.target_bank.sha256: + raise FlashAnalysisError( + "refusing to write because the live firmware bank changed since the saved backup: " + f"bank={plan.target_bank.name} live_sha256={live_sha256} backup_sha256={plan.target_bank.sha256}" + ) + + +def write_flash_plan( + *, + target: FlashTarget, + bundle: FlashAnalysisBundle, + plan: FlashPlan, + log: object | None = None, +) -> dict[str, object]: + if plan.target_bank is None or plan.payload is None: + raise FlashAnalysisError("flash plan has no write payload") + record_write_outcome( + bundle=bundle, + plan=plan, + status="attempting", + write_validated=False, + write_may_have_modified_device=True, + stage="write_primary_bank" if plan.mode == "patch" else "write_active_bank", + ) + write_result = write_and_validate_plan( + connection=target.connection, + acp_host=target.acp_host, + plan=plan, + os_release=target.compatibility.os_release, + flash_firmware_bank_func=flash_firmware_bank, + dump_remote_bank_func=partial(dump_remote_bank_for_validation, log=log), + get_property_int_func=partial(get_property_int_for_validation, log=log), + timeout=FLASH_WRITE_TIMEOUT_SECONDS, + ) + record_write_outcome( + bundle=bundle, + plan=plan, + status="validated", + write_validated=True, + write_may_have_modified_device=True, + write_result=write_result, + ) + return write_result diff --git a/tests/test_app_api.py b/tests/test_app_api.py index f081e539..e2e5a4c0 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -8,7 +8,7 @@ import sys import tempfile import unittest -from contextlib import redirect_stdout +from contextlib import ExitStack, redirect_stdout from pathlib import Path from types import SimpleNamespace from unittest import mock @@ -39,6 +39,7 @@ from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance from timecapsulesmb.integrations.acp import ACPAuthError from timecapsulesmb.services.app import AppOperationError, jsonable +from timecapsulesmb.services.flash import STALE_BACKUP_AFTER_WRITE_MESSAGE, require_backup_fresh_for_plan from timecapsulesmb.transport.errors import SshCommandTimeout, SshError, TransportError from timecapsulesmb.transport.ssh import SshConnection @@ -155,6 +156,22 @@ def managed_runtime_probe(ready: bool = True) -> ManagedRuntimeProbeResult: class AppApiTests(unittest.TestCase): + def setUp(self) -> None: + self._exit_stack = ExitStack() + self._telemetry_client = mock.Mock() + # App API tests exercise GUI/backend telemetry-enabled operations. + # Keep telemetry mocked here so unit tests never POST to the live telemetry service. + self._telemetry_factory = self._exit_stack.enter_context( + mock.patch("timecapsulesmb.app.service.TelemetryClient.from_config", return_value=self._telemetry_client) + ) + # This tripwire catches future tests that accidentally bypass the app-service telemetry mock. + self._telemetry_urlopen = self._exit_stack.enter_context( + mock.patch("timecapsulesmb.telemetry.urllib.request.urlopen", side_effect=AssertionError("tests must not send telemetry")) + ) + + def tearDown(self) -> None: + self._exit_stack.close() + def assert_single_terminal_event(self, collector: CollectingSink, event_type: str) -> dict[str, object]: terminals = collector.events_of_type("result") + collector.events_of_type("error") self.assertEqual([event["type"] for event in terminals], [event_type]) @@ -298,9 +315,254 @@ def test_capabilities_returns_helper_contract_details(self) -> None: self.assertIn("capabilities", payload["operations"]) self.assertIn("set-telemetry", payload["operations"]) self.assertIn("version-check", payload["operations"]) + self.assertIn("flash", payload["operations"]) self.assertIn("helper_version", payload) self.assertIn("artifact_manifest_sha256", payload) + def test_flash_backup_operation_returns_manifest_payload(self) -> None: + collector = CollectingSink() + manifest = { + "backup_dir": "/tmp/flash-backup", + "banks": [{"name": "primary"}, {"name": "secondary"}], + } + bundle = SimpleNamespace(manifest=manifest) + + with mock.patch("timecapsulesmb.app.ops.flash._load_flash_config", return_value=object()): + with mock.patch("timecapsulesmb.app.ops.flash._resolve_flash_target", return_value=object()): + with mock.patch("timecapsulesmb.app.ops.flash.backup_flash", return_value=bundle) as backup_mock: + rc = service.run_api_request( + {"operation": "flash", "params": {"action": "backup", "credentials": {"password": "pw"}}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + backup_mock.assert_called_once() + self.assertIn("stage", backup_mock.call_args.kwargs) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertEqual(payload["backup_dir"], "/tmp/flash-backup") + self.assertEqual(payload["counts"], {"banks": 2}) + + def test_flash_backup_accepts_request_scoped_password(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values( + {"TC_HOST": "root@10.0.0.2"}, + file_values={"TC_HOST": "root@10.0.0.2"}, + ) + manifest = { + "backup_dir": "/tmp/flash-backup", + "banks": [{"name": "primary"}], + } + bundle = SimpleNamespace(manifest=manifest) + + with mock.patch("timecapsulesmb.app.ops.flash.load_env_config", return_value=config): + with mock.patch( + "timecapsulesmb.app.ops.flash.require_connection_compatibility", + return_value=supported_compatibility("netbsd4be_samba4"), + ): + with mock.patch("timecapsulesmb.app.ops.flash.backup_flash", return_value=bundle) as backup_mock: + rc = service.run_api_request( + { + "operation": "flash", + "params": {"action": "backup", "credentials": {"password": "request-pw"}}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + target = backup_mock.call_args.kwargs["target"] + self.assertEqual(target.connection.password, "request-pw") + self.assertFalse(config.has_file_value("TC_PASSWORD")) + + def test_flash_plan_operation_uses_saved_backup_without_device_config(self) -> None: + collector = CollectingSink() + manifest = { + "backup_dir": "/tmp/flash-backup", + "flash_plan": { + "mode": "check_apple", + "write_requested": False, + "already_satisfied": True, + "apple_match": { + "matched": True, + "template_source": "catalog", + "template_version": "7.8.1", + "template_product_id": "116", + "template_sha256": "template-sha", + "inner_sha256": "inner-sha", + "inner_size": 123, + "key_id": "key-one", + "inner_model": 116, + "inner_version": "0x00070801", + }, + }, + } + bundle = SimpleNamespace(manifest=manifest) + + with mock.patch("timecapsulesmb.app.ops.flash.plan_flash_from_backup", return_value=(bundle, object())) as plan_mock: + with mock.patch("timecapsulesmb.app.ops.flash._load_flash_config", side_effect=AssertionError("plan should not load device config")): + rc = service.run_api_request( + { + "operation": "flash", + "params": { + "action": "plan", + "backup_dir": "/tmp/flash-backup", + "mode": "check_apple", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + plan_mock.assert_called_once() + self.assertEqual(plan_mock.call_args.kwargs["operation"], "check_apple") + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertEqual(payload["mode"], "check_apple") + self.assertFalse(payload["write_requested"]) + self.assertEqual(payload["summary"], "Active firmware bank matches Apple stock firmware 7.8.1.") + self.assertEqual(payload["apple_firmware_match"]["matched"], True) + self.assertEqual(payload["apple_firmware_match"]["template_version"], "7.8.1") + self.assertIsNone(payload["firmware_payload"]) + + def test_flash_plan_payload_promotes_download_payload_and_saved_path(self) -> None: + payload = contracts.flash_plan_payload({ + "backup_dir": "/tmp/flash-backup", + "files": { + "secondary_download_only_basebinary_payload": "/tmp/flash-backup/secondary.download_only.basebinary", + }, + "flash_plan": { + "mode": "download_only", + "target_bank": "secondary", + "write_requested": False, + "already_satisfied": False, + "apple_match": { + "matched": False, + "template_source": "catalog", + "template_version": "7.8.1", + }, + "payload": { + "template_source": "catalog", + "template_path": "/Users/example/Library/Application Support/TimeCapsuleSMB/firmware.basebinary", + "template_product_id": "116", + "template_version": "7.8.1", + "template_sha256": "template-sha", + "payload_sha256": "payload-sha", + "payload_size": 456, + "expected_prefix_sha256": "prefix-sha", + "expected_prefix_size": 123, + "key_id": "key-one", + "inner_model": 116, + "inner_version": "0x00070801", + "inner_payload_size": 123, + }, + }, + }) + + self.assertEqual(payload["summary"], "Apple restore firmware validated (version 7.8.1, product 116).") + self.assertEqual(payload["firmware_payload"]["payload_sha256"], "payload-sha") + self.assertEqual( + payload["firmware_payload_path"], + "/tmp/flash-backup/secondary.download_only.basebinary", + ) + self.assertEqual(payload["apple_firmware_match"]["matched"], False) + + def test_flash_plan_rejects_backup_manifest_used_for_write(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + backup_dir = Path(tmp) + (backup_dir / "manifest.json").write_text(json.dumps({ + "write_outcome": { + "status": "validated", + "mode": "patch", + "write_may_have_modified_device": True, + }, + })) + collector = CollectingSink() + + rc = service.run_api_request( + { + "operation": "flash", + "params": { + "action": "plan", + "backup_dir": str(backup_dir), + "mode": "restore", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["message"], STALE_BACKUP_AFTER_WRITE_MESSAGE) + + def test_flash_backup_freshness_allows_noop_or_cancelled_write_outcomes(self) -> None: + for status in ("not_needed", "cancelled"): + with self.subTest(status=status): + require_backup_fresh_for_plan({ + "write_outcome": { + "status": status, + "write_may_have_modified_device": False, + }, + }) + + def test_flash_write_requires_confirmation_then_validates_and_writes(self) -> None: + manifest = { + "backup_dir": "/tmp/flash-backup", + "write_outcome": { + "status": "validated", + "mode": "patch", + "write_validated": True, + }, + } + target_bank = SimpleNamespace(name="primary", sha256="bank-sha") + plan = SimpleNamespace(already_satisfied=False, target_bank=target_bank) + bundle = SimpleNamespace(manifest=manifest, backup_dir=Path("/tmp/flash-backup")) + target = SimpleNamespace(acp_host="10.0.0.2", connection=object()) + + def run(params: dict[str, object]) -> CollectingSink: + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.ops.flash.plan_flash_from_backup", return_value=(bundle, plan)): + with mock.patch("timecapsulesmb.app.ops.flash._load_flash_config", return_value=object()): + with mock.patch("timecapsulesmb.app.ops.flash._resolve_flash_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.flash.validate_live_target_matches_backup") as validate_mock: + with mock.patch("timecapsulesmb.app.ops.flash.write_flash_plan") as write_mock: + rc = service.run_api_request({"operation": "flash", "params": params}, collector.sink) + collector.rc = rc # type: ignore[attr-defined] + collector.validate_mock = validate_mock # type: ignore[attr-defined] + collector.write_mock = write_mock # type: ignore[attr-defined] + return collector + + first = run({"action": "write", "backup_dir": "/tmp/flash-backup", "mode": "patch"}) + + self.assertEqual(first.rc, 1) # type: ignore[attr-defined] + details = self.assert_confirmation(first, "flash.patch_write", {"host": "10.0.0.2", "mode": "patch"}) + first.validate_mock.assert_not_called() # type: ignore[attr-defined] + first.write_mock.assert_not_called() # type: ignore[attr-defined] + + second = run({ + "action": "write", + "backup_dir": "/tmp/flash-backup", + "mode": "patch", + "confirmation_id": details["confirmation_id"], + }) + + self.assertEqual(second.rc, 0) # type: ignore[attr-defined] + second.validate_mock.assert_called_once() # type: ignore[attr-defined] + second.write_mock.assert_called_once() # type: ignore[attr-defined] + payload = self.assert_single_terminal_event(second, "result")["payload"] + self.assertEqual(payload["write_status"], "validated") + self.assertTrue(payload["write_validated"]) + + def test_flash_write_payload_restore_summary_mentions_manual_power_cycle(self) -> None: + payload = contracts.flash_write_payload({ + "backup_dir": "/tmp/flash-backup", + "write_outcome": { + "status": "validated", + "mode": "restore", + "write_validated": True, + }, + }) + + self.assertEqual(payload["summary"], "flash restore write validated; manual power cycle required.") + def test_set_telemetry_operation_updates_bootstrap_preference(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" @@ -345,7 +607,7 @@ def test_version_check_operation_returns_structured_update_status(self) -> None: checked_url="https://example.invalid/version.json", message="Please update.", download_url="https://example.invalid/download", - local_version_code=20004, + local_version_code=20000, current_version=20005, min_supported_version=20005, latest_tag="v2.0.5", @@ -463,7 +725,6 @@ def fail(_params, _sink): def test_dispatcher_emits_api_operation_telemetry(self) -> None: collector = CollectingSink() - telemetry = mock.Mock() def run_fsck(params, sink): sink.stage("fsck", "run_fsck") @@ -481,25 +742,24 @@ def run_fsck(params, sink): with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): with mock.patch("timecapsulesmb.app.service.ensure_install_id"): with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): - with mock.patch("timecapsulesmb.app.service.TelemetryClient.from_config", return_value=telemetry): - rc = service.run_api_request( - { - "operation": "fsck", - "params": { - "volume": "Data", - "dry_run": False, - "no_reboot": False, - "no_wait": False, - "mount_wait": 30, - }, + rc = service.run_api_request( + { + "operation": "fsck", + "params": { + "volume": "Data", + "dry_run": False, + "no_reboot": False, + "no_wait": False, + "mount_wait": 30, }, - collector.sink, - ) + }, + collector.sink, + ) self.assertEqual(rc, 0) - self.assertEqual(telemetry.emit.call_count, 2) - started = telemetry.emit.call_args_list[0] - finished = telemetry.emit.call_args_list[1] + self.assertEqual(self._telemetry_client.emit.call_count, 2) + started = self._telemetry_client.emit.call_args_list[0] + finished = self._telemetry_client.emit.call_args_list[1] self.assertEqual(started.args, ("fsck_started",)) self.assertEqual(started.kwargs["operation"], "fsck") self.assertEqual(started.kwargs["phase"], "started") @@ -527,7 +787,6 @@ def run_fsck(params, sink): def test_dispatcher_emits_confirmation_required_telemetry(self) -> None: collector = CollectingSink() - telemetry = mock.Mock() def run_fsck(params, sink): sink.stage("fsck", "select_fsck_volume") @@ -548,15 +807,14 @@ def run_fsck(params, sink): with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): with mock.patch("timecapsulesmb.app.service.ensure_install_id"): with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): - with mock.patch("timecapsulesmb.app.service.TelemetryClient.from_config", return_value=telemetry): - rc = service.run_api_request( - {"operation": "fsck", "params": {"volume": "Data"}}, - collector.sink, - ) + rc = service.run_api_request( + {"operation": "fsck", "params": {"volume": "Data"}}, + collector.sink, + ) self.assertEqual(rc, 1) - self.assertEqual(telemetry.emit.call_count, 2) - finished_kwargs = telemetry.emit.call_args_list[1].kwargs + self.assertEqual(self._telemetry_client.emit.call_count, 2) + finished_kwargs = self._telemetry_client.emit.call_args_list[1].kwargs self.assertEqual(finished_kwargs["result"], "confirmation_required") self.assertIsNone(finished_kwargs["error"]) self.assertEqual(finished_kwargs["risk"], "destructive") @@ -565,12 +823,30 @@ def run_fsck(params, sink): def test_dispatcher_does_not_emit_readiness_operation_telemetry(self) -> None: collector = CollectingSink() + self._telemetry_factory.reset_mock() + + rc = service.run_api_request({"operation": "paths", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + self._telemetry_factory.assert_not_called() + + def test_app_api_telemetry_tests_do_not_open_network_connections(self) -> None: + collector = CollectingSink() - with mock.patch("timecapsulesmb.app.service.TelemetryClient.from_config") as telemetry_factory: - rc = service.run_api_request({"operation": "paths", "params": {}}, collector.sink) + def run_fsck(_params, sink): + sink.stage("fsck", "run_fsck") + return service.OperationResult(True, {"returncode": 0, "summary": "fsck completed."}) + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) self.assertEqual(rc, 0) - telemetry_factory.assert_not_called() + self._telemetry_factory.assert_called_once() + self.assertEqual(self._telemetry_client.emit.call_count, 2) + self._telemetry_urlopen.assert_not_called() def test_discover_operation_returns_snapshot_payload(self) -> None: collector = CollectingSink() diff --git a/tests/test_config.py b/tests/test_config.py index c2c5fda5..62c6c49a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -562,7 +562,17 @@ def test_flash_profile_ignores_deploy_only_settings(self) -> None: self.assertEqual(validate_app_config(config, profile="flash"), []) - def test_flash_profile_requires_password(self) -> None: + def test_flash_profile_accepts_request_scoped_password(self) -> None: + values = dict(DEFAULTS) + values["TC_HOST"] = "root@10.0.0.2" + values["TC_PASSWORD"] = "pw" + file_values = dict(values) + file_values.pop("TC_PASSWORD", None) + config = AppConfig.from_values(values, file_values=file_values) + + self.assertEqual(validate_app_config(config, profile="flash"), []) + + def test_flash_profile_still_requires_effective_password(self) -> None: values = dict(DEFAULTS) values["TC_HOST"] = "root@10.0.0.2" file_values = dict(values) diff --git a/tests/test_macos_package_app.py b/tests/test_macos_package_app.py index 2576d240..6f3913c6 100644 --- a/tests/test_macos_package_app.py +++ b/tests/test_macos_package_app.py @@ -85,6 +85,7 @@ def test_assert_bundle_layout_checks_helper_python_tools_and_artifacts( python.write_text("#!/bin/sh\n", encoding="utf-8") helper.chmod(0o755) python.chmod(0o755) + (distribution / "artifact-manifest.json").write_text('{"artifacts":{}}', encoding="utf-8") monkeypatch.setattr(package_app, "artifact_paths", lambda: ["bin/payloads/one", "bin/payloads/two"]) (distribution / "bin" / "payloads" / "one").write_text("one", encoding="utf-8") @@ -95,3 +96,21 @@ def test_assert_bundle_layout_checks_helper_python_tools_and_artifacts( (distribution / "bin" / "payloads" / "two").write_text("two", encoding="utf-8") package_app.assert_bundle_layout(app) + + +def test_assert_bundle_layout_requires_artifact_manifest(tmp_path: Path) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + python = app / "Contents" / "Resources" / "Python" / "bin" / "python" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, python.parent, tools, distribution / "bin"): + directory.mkdir(parents=True) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + python.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + python.chmod(0o755) + + with pytest.raises(RuntimeError, match="missing bundled artifact manifest"): + package_app.assert_bundle_layout(app) From ddde78fb2237eb5e66b1ea5efa56fc8f883d365a Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 04:50:57 -0700 Subject: [PATCH 041/129] Add localization --- .../TimeCapsuleSMBApp/App/AppSettings.swift | 63 ++ .../TimeCapsuleSMBApp/App/AppStore.swift | 69 +- .../TimeCapsuleSMBApp/App/Localization.swift | 52 +- .../Backend/BackendPayloads.swift | 28 + .../Backend/OperationParams.swift | 13 + .../Policies/DashboardActionPolicy.swift | 12 +- .../Policies/SMBAddressPolicy.swift | 8 + .../Profiles/DeviceProfileEditorStore.swift | 78 ++- .../Resources/en.lproj/Localizable.strings | 20 +- .../zh-Hans.lproj/Localizable.strings | 644 ++++++++++++++++++ .../Views/Dashboard/DeviceDashboardView.swift | 3 + .../Views/Dashboard/OverviewTab.swift | 41 +- .../Views/Dashboard/SettingsTab.swift | 18 + .../Views/Shell/AppSettingsView.swift | 13 + .../DashboardOverviewPresentation.swift | 191 +++++- .../Workflows/DeviceDashboardSession.swift | 58 +- .../Workflows/DeviceReachabilityStore.swift | 154 +++++ .../Workflows/FlashPresentation.swift | 4 + .../Workflows/OperationTimeline.swift | 14 + .../AppSettingsStoreTests.swift | 57 ++ .../BackendPayloadTests.swift | 15 + .../DashboardPresentationTests.swift | 131 +++- .../DashboardStoreTests.swift | 63 +- .../DeviceProfileEditorStoreTests.swift | 105 +++ .../DeviceReachabilityStoreTests.swift | 98 +++ .../FlashWorkflowStoreTests.swift | 5 + .../StoreTestSupport.swift | 42 +- src/timecapsulesmb/app/contracts.py | 22 + src/timecapsulesmb/app/ops/__init__.py | 2 + src/timecapsulesmb/app/ops/reachability.py | 30 + src/timecapsulesmb/app/ops/readiness.py | 1 + src/timecapsulesmb/app/stage_policy.py | 7 + src/timecapsulesmb/services/reachability.py | 437 ++++++++++++ tests/test_app_api.py | 1 + tests/test_reachability.py | 218 ++++++ 35 files changed, 2553 insertions(+), 164 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceReachabilityStore.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceReachabilityStoreTests.swift create mode 100644 src/timecapsulesmb/app/ops/reachability.py create mode 100644 src/timecapsulesmb/services/reachability.py create mode 100644 tests/test_reachability.py diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift index 92d56747..4bef1310 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift @@ -1,7 +1,62 @@ import Combine import Foundation +enum AppLanguage: String, CaseIterable, Codable, Identifiable, Equatable { + case system + case english = "en" + case simplifiedChinese = "zh-Hans" + + var id: String { + rawValue + } + + var title: String { + switch self { + case .system: + return L10n.string("app_language.system") + case .english: + return L10n.string("app_language.english") + case .simplifiedChinese: + return L10n.string("app_language.simplified_chinese") + } + } + + var localizationIdentifier: String? { + switch self { + case .system: + return nil + case .english: + return rawValue + case .simplifiedChinese: + return rawValue + } + } + + var locale: Locale { + switch self { + case .system: + return .current + case .english: + return Locale(identifier: "en") + case .simplifiedChinese: + return Locale(identifier: "zh-Hans") + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = AppLanguage(rawValue: rawValue) ?? .system + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + struct AppSettings: Codable, Equatable { + var language: AppLanguage var defaultBonjourTimeoutSeconds: Double var defaultDeviceSettings: DeviceProfileSettings var telemetryEnabled: Bool @@ -12,6 +67,7 @@ struct AppSettings: Codable, Equatable { var timeMachineWarningsEnabled: Bool static let `default` = AppSettings( + language: .system, defaultBonjourTimeoutSeconds: 6, defaultDeviceSettings: .default, telemetryEnabled: true, @@ -23,6 +79,7 @@ struct AppSettings: Codable, Equatable { ) init( + language: AppLanguage = .system, defaultBonjourTimeoutSeconds: Double, defaultDeviceSettings: DeviceProfileSettings, telemetryEnabled: Bool, @@ -32,6 +89,7 @@ struct AppSettings: Codable, Equatable { versionCheckURL: String, timeMachineWarningsEnabled: Bool ) { + self.language = language self.defaultBonjourTimeoutSeconds = defaultBonjourTimeoutSeconds self.defaultDeviceSettings = defaultDeviceSettings self.telemetryEnabled = telemetryEnabled @@ -43,6 +101,7 @@ struct AppSettings: Codable, Equatable { } private enum CodingKeys: String, CodingKey { + case language case defaultBonjourTimeoutSeconds case defaultDeviceSettings case telemetryEnabled @@ -56,6 +115,7 @@ struct AppSettings: Codable, Equatable { init(from decoder: Decoder) throws { let defaults = Self.default let container = try decoder.container(keyedBy: CodingKeys.self) + language = try container.decodeIfPresent(AppLanguage.self, forKey: .language) ?? defaults.language defaultBonjourTimeoutSeconds = Self.decodeNonNegativeDouble( from: container, forKey: .defaultBonjourTimeoutSeconds, @@ -240,6 +300,7 @@ private actor AppSettingsRepository { } struct AppSettingsDraft: Equatable { + var language: AppLanguage var defaultBonjourTimeoutSeconds: String var nbnsEnabled: Bool var internalShareUseDiskRoot: Bool @@ -256,6 +317,7 @@ struct AppSettingsDraft: Equatable { var timeMachineWarningsEnabled: Bool init(settings: AppSettings) { + language = settings.language defaultBonjourTimeoutSeconds = Self.formatDouble(settings.defaultBonjourTimeoutSeconds) nbnsEnabled = settings.defaultDeviceSettings.nbnsEnabled internalShareUseDiskRoot = settings.defaultDeviceSettings.internalShareUseDiskRoot @@ -298,6 +360,7 @@ struct AppSettingsDraft: Equatable { } return AppSettings( + language: language, defaultBonjourTimeoutSeconds: bonjourTimeout, defaultDeviceSettings: DeviceProfileSettings( nbnsEnabled: nbnsEnabled, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift index fe4fd1cd..b8fee7d9 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift @@ -3,6 +3,11 @@ import Foundation @MainActor final class AppStore: ObservableObject { + private enum PasswordRollback { + case delete + case restore(String) + } + @Published var selectedDeviceID: DeviceProfile.ID? @Published var showingAddDevice = false @Published var showingActivity = false @@ -16,6 +21,7 @@ final class AppStore: ObservableObject { let passwordStore: PasswordStore let activityStore: ActivityStore let discoveryMonitor: DeviceDiscoveryMonitorStore + let reachabilityStore: DeviceReachabilityStore private var cancellables: Set = [] @@ -39,7 +45,8 @@ final class AppStore: ObservableObject { passwordStore: PasswordStore, activityStore: ActivityStore? = nil, appUpdateStore: AppUpdateStore? = nil, - discoveryMonitor: DeviceDiscoveryMonitorStore? = nil + discoveryMonitor: DeviceDiscoveryMonitorStore? = nil, + reachabilityStore: DeviceReachabilityStore? = nil ) { self.appReadinessStore = appReadinessStore self.appSettingsStore = appSettingsStore ?? AppSettingsStore() @@ -53,6 +60,7 @@ final class AppStore: ObservableObject { readinessStore: appReadinessStore, registry: deviceRegistry ) + self.reachabilityStore = reachabilityStore ?? DeviceReachabilityStore(coordinator: operationCoordinator) appReadinessStore.objectWillChange .sink { [weak self] _ in @@ -89,6 +97,11 @@ final class AppStore: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) + self.reachabilityStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) deviceRegistry.$profiles .sink { [weak self] profiles in Task { @MainActor in @@ -197,17 +210,33 @@ final class AppStore: ObservableObject { } } - func savePassword(_ password: String, for profile: DeviceProfile) async throws { - try passwordStore.save(password, for: profile.keychainAccount) - await deviceRegistry.updatePasswordState(.available, for: profile.id) - } - @discardableResult - func saveProfileEdits(profile: DeviceProfile, fields: DeviceProfileEditableFields) async throws -> DeviceProfile { + func saveProfileEdits( + profile: DeviceProfile, + fields: DeviceProfileEditableFields, + replacementPassword: String? = nil + ) async throws -> DeviceProfile { var updated = profile updated.displayName = fields.displayName updated.settings = fields.settings - return try await deviceRegistry.updateProfile(updated) + + let rollback: PasswordRollback? + if let replacementPassword { + rollback = try passwordRollback(for: profile.keychainAccount) + try passwordStore.save(replacementPassword, for: profile.keychainAccount) + updated.passwordState = .available + } else { + rollback = nil + } + + do { + return try await deviceRegistry.updateProfile(updated) + } catch { + if let rollback { + rollbackPassword(rollback, account: profile.keychainAccount) + } + throw error + } } func forget(_ profile: DeviceProfile) async throws { @@ -258,17 +287,41 @@ final class AppStore: ObservableObject { } private func applyAppSettings(_ settings: AppSettings) { + let previousLanguage = L10n.currentLanguage + L10n.apply(language: settings.language) if backend.helperPath != settings.helperPathOverride { backend.helperPath = settings.helperPathOverride } appReadinessStore.applyVersionCheck(readinessVersionCheck(for: settings)) discoveryMonitor.applyAppSettings(settings) + if previousLanguage != settings.language { + objectWillChange.send() + } } private func readinessVersionCheck(for settings: AppSettings) -> AppReadinessVersionCheck { AppReadinessVersionCheck(url: settings.versionCheckURL) } + private func passwordRollback(for account: String) throws -> PasswordRollback { + do { + return .restore(try passwordStore.password(for: account)) + } catch PasswordStoreError.missing { + return .delete + } catch { + throw error + } + } + + private func rollbackPassword(_ rollback: PasswordRollback, account: String) { + switch rollback { + case .delete: + try? passwordStore.deletePassword(for: account) + case .restore(let password): + try? passwordStore.save(password, for: account) + } + } + private func syncTelemetryPreference(_ enabled: Bool) { let params: [String: JSONValue] = ["enabled": .bool(enabled)] _ = operationCoordinator.run( diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift index 7ac25032..6f4d0731 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift @@ -1,11 +1,59 @@ import Foundation enum L10n { + private static let lock = NSLock() + private static var selectedLanguage: AppLanguage = .system + + static var currentLanguage: AppLanguage { + lock.lock() + defer { lock.unlock() } + return selectedLanguage + } + + static func apply(language: AppLanguage) { + lock.lock() + selectedLanguage = language + lock.unlock() + } + static func string(_ key: String) -> String { - NSLocalizedString(key, bundle: .module, comment: "") + string(key, language: currentLanguage) } static func format(_ key: String, _ arguments: CVarArg...) -> String { - String(format: string(key), locale: Locale.current, arguments: arguments) + let language = currentLanguage + return String(format: string(key, language: language), locale: language.locale, arguments: arguments) + } + + static func string(_ key: String, language: AppLanguage) -> String { + let fallback = NSLocalizedString(key, bundle: .module, comment: "") + guard let bundle = bundle(for: language) else { + return fallback + } + return bundle.localizedString(forKey: key, value: fallback, table: nil) + } + + static func strings(language: AppLanguage) -> [String: String] { + guard let bundle = bundle(for: language) ?? bundle(for: .english), + let url = bundle.url(forResource: "Localizable", withExtension: "strings"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let strings = plist as? [String: String] else { + return [:] + } + return strings + } + + private static func bundle(for language: AppLanguage) -> Bundle? { + guard let identifier = language.localizationIdentifier else { + return nil + } + for candidate in [identifier, identifier.lowercased()] { + if let path = Bundle.module.path(forResource: candidate, ofType: "lproj"), + let bundle = Bundle(path: path) { + return bundle + } + } + return nil } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift index 18c32447..ac1a53cf 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift @@ -126,6 +126,34 @@ struct VersionCheckPayload: Decodable, Equatable { } } +struct ReachabilityPayload: Decodable, Equatable { + let schemaVersion: Int + let status: String + let sshHost: String? + let smbHost: String? + let checks: [ReachabilityCheckPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case status + case sshHost = "ssh_host" + case smbHost = "smb_host" + case checks + case counts + case summary + } +} + +struct ReachabilityCheckPayload: Decodable, Equatable { + let id: String + let status: String + let message: String + let host: String? + let detail: String? +} + struct InstallCheckPayload: Decodable, Equatable { let id: String let ok: Bool diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift index 10a5dfbc..321a584e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift @@ -37,6 +37,19 @@ enum OperationParams { return params } + static func reachability(profile: DeviceProfile, password: String?) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "ssh_host": .string(rootSSHTarget(profile.host)), + "smb_hosts": .array(SMBAddressPolicy.reachabilityHostCandidates(for: profile).map(JSONValue.string)), + "tcp_timeout": .number(2), + "ssh_timeout": .number(8) + ] + if let password { + params = withCredentials(params, password: password) + } + return params + } + static func configure( host: String = "", selectedRecord: JSONValue? = nil, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift index 6c293f3c..32c73455 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift @@ -9,9 +9,6 @@ enum DashboardActionPolicy { if summary.profile.lastDeploy != nil && summary.primaryAction != .openSMB { actions.append(.openFinder) } - if !requiresPasswordReplacement(summary.passwordState) { - actions.append(.replacePassword) - } actions.append(.settings) return removingDuplicates(actions.filter { isAvailable($0, for: summary) }) } @@ -52,7 +49,8 @@ enum DashboardActionPolicy { switch action { case .runCheckup: return summary.displayStatus != .checking - case .installUpdate, + case .refreshStatus, + .installUpdate, .openFinder, .replacePassword, .viewCheckup, @@ -88,7 +86,7 @@ enum DashboardActionPolicy { } } -private extension DashboardPrimaryAction { +extension DashboardPrimaryAction { var isMutatingOverviewAction: Bool { switch self { case .runCheckup, .installSMB: @@ -99,10 +97,10 @@ private extension DashboardPrimaryAction { } } -private extension DashboardSecondaryAction { +extension DashboardSecondaryAction { var isMutatingOverviewAction: Bool { switch self { - case .runCheckup, .installUpdate: + case .refreshStatus, .runCheckup, .installUpdate: return true case .openFinder, .replacePassword, .viewCheckup, .startSMB, .settings: return false diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift index c97b7076..da5ecae8 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift @@ -31,6 +31,14 @@ enum SMBAddressPolicy { ] + profile.network.addresses.map { normalizedAddressHost($0.value) }) } + static func reachabilityHostCandidates(for profile: DeviceProfile) -> [String] { + unique([ + preferredHost(for: profile), + normalizedAddressHost(profile.hostname), + normalizedAddressHost(profile.host) + ] + profile.network.addresses.map { normalizedAddressHost($0.value) }) + } + private static func bonjourSMBServiceHost(for profile: DeviceProfile) -> String? { if let fullname = profile.bonjourFullname?.trimmingCharacters(in: .whitespacesAndNewlines), !fullname.isEmpty { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift index 000fa1d3..fac1fd98 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift @@ -159,6 +159,10 @@ final class DeviceProfileEditorStore: ObservableObject { @Published private(set) var error: BackendErrorViewModel? @Published private(set) var currentStage: OperationStageState? @Published private(set) var savedProfile: DeviceProfile? + @Published var replacementPassword = "" { + didSet { markDirtyAfterPasswordChange() } + } + @Published private(set) var passwordError: String? private let appStore: AppStore private let coordinator: OperationCoordinator @@ -171,6 +175,7 @@ final class DeviceProfileEditorStore: ObservableObject { private var pendingPassword: String? private var lastProcessedEventCount = 0 private var isApplyingDraft = false + private var isApplyingPasswordDraft = false private var cancellables: Set = [] init( @@ -196,7 +201,15 @@ final class DeviceProfileEditorStore: ObservableObject { } var canSave: Bool { - !isRunning && draft != baselineDraft + !isRunning && hasPendingChanges + } + + private var hasPendingPasswordChange: Bool { + !replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var hasPendingChanges: Bool { + draft != baselineDraft || hasPendingPasswordChange } func sync(to profile: DeviceProfile) { @@ -205,7 +218,7 @@ final class DeviceProfileEditorStore: ObservableObject { return } - let wasClean = draft == baselineDraft + let wasClean = !hasPendingChanges baselineDraft = profileDraft guard !isRunning else { return @@ -227,6 +240,8 @@ final class DeviceProfileEditorStore: ObservableObject { let profileDraft = DeviceProfileEditorDraft(profile: profile) baselineDraft = profileDraft applyDraft(profileDraft) + applyPasswordDraft("") + passwordError = nil validationErrors = [] error = nil currentStage = nil @@ -254,16 +269,40 @@ final class DeviceProfileEditorStore: ObservableObject { return } + let pendingReplacementPassword = hasPendingPasswordChange ? replacementPassword : nil + if draft.hostChanged(from: profile) { - guard let password = appStore.password(for: profile) else { + guard let password = pendingReplacementPassword ?? appStore.password(for: profile) else { self.validationErrors = [.passwordRequired] + passwordError = L10n.string("password.error.required") error = nil state = .invalid return } startReconfigure(profile: profile, password: password, settings: settings) } else { - await saveRegistryOnly(profile: profile) + await saveRegistryOnly(profile: profile, replacementPassword: pendingReplacementPassword) + } + } + + func requestPasswordReplacement(error: String?) { + if !hasPendingPasswordChange { + applyPasswordDraft("") + } + passwordError = error + if error != nil { + validationErrors = [] + self.error = nil + state = .invalid + } else { + updateDraftChangeState() + } + } + + func clearPasswordAttention() { + passwordError = nil + if state == .invalid && validationErrors.isEmpty { + updateDraftChangeState() } } @@ -298,19 +337,28 @@ final class DeviceProfileEditorStore: ObservableObject { return errors } - private func saveRegistryOnly(profile: DeviceProfile) async { + private func saveRegistryOnly(profile: DeviceProfile, replacementPassword: String?) async { state = .saving validationErrors = [] error = nil currentStage = nil do { - let saved = try await appStore.saveProfileEdits(profile: profile, fields: draft.editableFields()) + let saved = try await appStore.saveProfileEdits( + profile: profile, + fields: draft.editableFields(), + replacementPassword: replacementPassword + ) savedProfile = saved let savedDraft = DeviceProfileEditorDraft(profile: saved) baselineDraft = savedDraft applyDraft(savedDraft) + applyPasswordDraft("") + passwordError = nil state = .saved } catch { + if replacementPassword != nil { + passwordError = error.localizedDescription + } failSave(error) } } @@ -422,6 +470,8 @@ final class DeviceProfileEditorStore: ObservableObject { let savedDraft = DeviceProfileEditorDraft(profile: saved) baselineDraft = savedDraft applyDraft(savedDraft) + applyPasswordDraft("") + passwordError = nil error = nil validationErrors = [] currentStage = nil @@ -492,6 +542,12 @@ final class DeviceProfileEditorStore: ObservableObject { isApplyingDraft = false } + private func applyPasswordDraft(_ password: String) { + isApplyingPasswordDraft = true + replacementPassword = password + isApplyingPasswordDraft = false + } + private func markDirtyAfterDraftChange() { guard !isApplyingDraft, !isRunning else { return @@ -499,10 +555,18 @@ final class DeviceProfileEditorStore: ObservableObject { updateDraftChangeState() } + private func markDirtyAfterPasswordChange() { + guard !isApplyingPasswordDraft, !isRunning else { + return + } + passwordError = nil + updateDraftChangeState() + } + private func updateDraftChangeState() { error = nil validationErrors = [] savedProfile = nil - state = draft == baselineDraft ? .clean : .dirty + state = hasPendingChanges ? .dirty : .clean } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 552c4945..07ced7f3 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -71,11 +71,13 @@ "app_settings.error.mount_wait" = "Mount wait must be a non-negative integer."; "app_settings.error.version_url" = "Version check URL must be blank or an HTTP/HTTPS URL."; "app_settings.helper_path" = "Helper path"; +"app_settings.language" = "Language"; "app_settings.restore_defaults" = "Restore Defaults"; "app_settings.reset_saved" = "Reset to Saved"; "app_settings.save" = "Save Settings"; "app_settings.section.defaults" = "New Device Defaults"; "app_settings.section.diagnostics" = "Helper and Diagnostics"; +"app_settings.section.general" = "General"; "app_settings.section.privacy" = "Privacy"; "app_settings.section.time_machine" = "Time Machine"; "app_settings.section.updates" = "Updates"; @@ -85,6 +87,9 @@ "app_settings.time_machine_warnings" = "Show macOS Time Machine compatibility warnings"; "app_settings.title" = "Settings"; "app_settings.version_url" = "Version metadata URL"; +"app_language.english" = "English"; +"app_language.simplified_chinese" = "简体中文"; +"app_language.system" = "System Default"; "app_update.state.checking" = "Checking for updates"; "app_update.state.current" = "TimeCapsuleSMB is up to date."; "app_update.state.failed" = "Update check failed."; @@ -172,21 +177,21 @@ "confirm.uninstall.reboot.title" = "Uninstall And Reboot?"; "dashboard.action.install_smb" = "Install SMB"; "dashboard.action.install_update_smb" = "Install / Update SMB"; +"dashboard.action.refresh_status" = "Refresh Status"; "dashboard.action.settings" = "Settings"; "dashboard.action.open_finder" = "Open Finder"; "dashboard.action.open_smb" = "Open Finder"; "dashboard.action.replace_password" = "Replace Password"; "dashboard.action.run_checkup" = "Run Checkup"; -"dashboard.action.save_password" = "Save Password"; "dashboard.action.start_smb" = "Activate"; "dashboard.action.view_checkup" = "View Checkup"; "dashboard.header.last_checked" = "Last checked"; "dashboard.health.check_counts" = "PASS %d, WARN %d, FAIL %d"; "dashboard.health.connection" = "Connection"; "dashboard.health.connection.keychain_unavailable" = "The saved password cannot be read from Keychain."; -"dashboard.health.connection.password_available" = "Saved password is available for backend operations."; +"dashboard.health.connection.not_refreshed" = "Connection status has not been refreshed."; "dashboard.health.connection.password_invalid" = "The saved password was rejected by the Time Capsule."; -"dashboard.health.connection.password_missing" = "Save the Time Capsule password before running checkups or installs."; +"dashboard.health.connection.refreshing" = "Checking DNS, SSH, and SMB reachability..."; "dashboard.health.connection.running" = "A backend operation is using this profile."; "dashboard.health.checkup" = "Checkup"; "dashboard.health.finder_bonjour" = "Finder / Bonjour"; @@ -337,7 +342,7 @@ "flash.action.plan_restore" = "Plan Restore"; "flash.action.write_patch" = "Write Patch"; "flash.action.write_restore" = "Write Restore"; -"flash.manual_power_cycle.message" = "Flash write validation completed. Unplug the Time Capsule, wait 10 seconds, then plug it back in to reboot it."; +"flash.manual_power_cycle.message" = "Flash write validation completed. Unplug the Time Capsule, wait 10 seconds, then plug it back in. Wait for it to finish booting, then run Checkup. One firmware bank was left untouched."; "flash.manual_power_cycle.title" = "Manual Reboot Required"; "flash.mode.check_apple" = "Check Apple Firmware"; "flash.mode.download_only" = "Validate Apple Restore Firmware"; @@ -572,6 +577,7 @@ "timeline.operation.doctor" = "Checkup"; "timeline.operation.flash" = "Persistent NetBSD4 Boot Hook"; "timeline.operation.fsck" = "Disk Repair"; +"timeline.operation.reachability" = "Reachability"; "timeline.operation.readiness" = "App Readiness"; "timeline.operation.repair_xattrs" = "File Metadata Repair"; "timeline.operation.telemetry" = "Telemetry Settings"; @@ -591,6 +597,12 @@ "timeline.stage.planning_install" = "Planning Install"; "timeline.stage.planning_start_smb" = "Planning Activation"; "timeline.stage.planning_uninstall" = "Planning Uninstall"; +"timeline.stage.reachability_candidates" = "Preparing Reachability Check"; +"timeline.stage.reachability_dns" = "Checking DNS"; +"timeline.stage.reachability_ping" = "Checking Ping"; +"timeline.stage.reachability_smb_port" = "Checking SMB Port"; +"timeline.stage.reachability_ssh_auth" = "Checking SSH Auth"; +"timeline.stage.reachability_ssh_port" = "Checking SSH Port"; "timeline.stage.rebooting" = "Rebooting"; "timeline.stage.removing_managed_files" = "Removing Managed Files"; "timeline.stage.repairing_disk" = "Repairing Disk"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..bfb5cdfd --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,644 @@ +"action.activate" = "Activate"; +"action.cancel" = "取消"; +"action.confirm" = "确认"; +"action.deploy" = "Deploy"; +"action.deploy_allow_reboot" = "Deploy 并允许重启"; +"action.done" = "完成"; +"action.ok" = "OK"; +"action.repair_xattrs" = "修复 xattrs"; +"action.run_fsck" = "运行 fsck"; +"action.uninstall" = "卸载"; +"add_device.connection_method" = "连接方式"; +"add_device.discover.placeholder" = "浏览 AirPort Bonjour 服务:"; +"add_device.discovered_devices" = "发现的设备"; +"add_device.entry.discover" = "发现"; +"add_device.entry.manual" = "手动地址"; +"add_device.error.choose_target" = "请选择发现的设备或输入 Host。"; +"add_device.error.invalid_bonjour_timeout" = "Bonjour timeout 必须是非负数。"; +"add_device.error.password_required" = "需要 Time Capsule 密码。"; +"add_device.host_or_ip" = "Hostname 或 IP 地址"; +"add_device.password" = "Time Capsule 密码"; +"add_device.progress.configuring.message" = "正在验证访问权限并准备此 Time Capsule。这可能需要几秒钟..."; +"add_device.progress.configuring.title" = "正在连接 Time Capsule"; +"add_device.progress.discovering.message" = "正在浏览附近的 AirPort Bonjour 服务..."; +"add_device.progress.discovering.title" = "正在发现 Time Capsule"; +"add_device.progress.saving.message" = "正在写入保存的设备 Profile 和 Keychain 密码。"; +"add_device.progress.saving.title" = "正在保存设备"; +"add_device.reset" = "重置"; +"add_device.save_device" = "保存设备"; +"add_device.saved" = "已保存 %@"; +"add_device.setup_target" = "Setup target:%@"; +"add_device.state.auth_failed" = "密码被拒绝"; +"add_device.state.awaiting_confirmation" = "等待确认"; +"add_device.state.configuring" = "正在配置"; +"add_device.state.discovering" = "正在发现"; +"add_device.state.discovery_empty" = "未找到设备"; +"add_device.state.discovery_ready" = "已找到设备"; +"add_device.state.failed" = "失败"; +"add_device.state.idle" = "空闲"; +"add_device.state.manual_entry" = "手动地址"; +"add_device.state.password_entry" = "需要密码"; +"add_device.state.saved" = "已保存"; +"add_device.state.saving_profile" = "正在保存"; +"add_device.state.unsupported" = "不支持"; +"add_device.title" = "添加 Time Capsule"; +"advanced.config" = "Config"; +"advanced.helper" = "Helper"; +"advanced.profile_id" = "Profile ID"; +"app_readiness.state.blocked" = "已阻止"; +"app_readiness.state.checking_capabilities" = "正在检查 Helper"; +"app_readiness.state.checking_version" = "正在检查版本"; +"app_readiness.state.degraded" = "降级"; +"app_readiness.state.idle" = "空闲"; +"app_readiness.state.ready" = "就绪"; +"app_readiness.state.resolving_bundle" = "正在准备 App Runtime"; +"app_readiness.state.validating_install" = "正在验证 bundled files"; +"app_readiness.error.unexpected_payload" = "%@ 返回了意外的 payload:%@"; +"app_readiness.recovery.contract_mismatch" = "请更新或重新安装 TimeCapsuleSMB,使 App 和 Helper 使用相同的 API contract。"; +"app_readiness.recovery.helper_missing" = "请重新安装 TimeCapsuleSMB,或在 Diagnostics 中选择有效的 Helper。"; +"app_readiness.recovery.install_validation_failed" = "请重新安装 TimeCapsuleSMB,或打开 Diagnostics 查看失败的检查项。"; +"app_readiness.recovery.retry_diagnostics" = "打开 Diagnostics 并重试 App Readiness。"; +"app_readiness.recovery.update_required" = "从 %@ 下载最新版本。"; +"app_readiness.recovery.version_metadata_unavailable" = "请检查网络连接,或稍后重试。"; +"app_settings.blank_uses_device_default" = "留空"; +"app_settings.check_now" = "立即检查"; +"app_settings.check_updates_on_launch" = "启动时检查更新"; +"app_settings.default_bonjour_timeout" = "Discovery timeout 秒数"; +"app_settings.error.ata_idle" = "ATA idle seconds 必须是非负整数。"; +"app_settings.error.ata_standby" = "ATA standby seconds 必须留空或为非负整数。"; +"app_settings.error.bonjour_timeout" = "Bonjour timeout 必须是非负数。"; +"app_settings.error.corrupt" = "无法读取 App settings:%@"; +"app_settings.error.mount_wait" = "Mount wait 必须是非负整数。"; +"app_settings.error.version_url" = "Version check URL 必须留空,或为 HTTP/HTTPS URL。"; +"app_settings.helper_path" = "Helper path"; +"app_settings.language" = "语言"; +"app_settings.restore_defaults" = "恢复默认值"; +"app_settings.reset_saved" = "重置为已保存"; +"app_settings.save" = "保存设置"; +"app_settings.section.defaults" = "新设备默认值"; +"app_settings.section.diagnostics" = "Helper 和 Diagnostics"; +"app_settings.section.general" = "通用"; +"app_settings.section.privacy" = "隐私"; +"app_settings.section.time_machine" = "Time Machine"; +"app_settings.section.updates" = "更新"; +"app_settings.show_raw_events" = "在 Diagnostics 中显示 raw Backend events"; +"app_settings.subtitle" = "新设备默认值和 App 级别行为。"; +"app_settings.telemetry_enabled" = "分享匿名 CLI telemetry"; +"app_settings.time_machine_warnings" = "显示 macOS Time Machine 兼容性警告"; +"app_settings.title" = "设置"; +"app_settings.version_url" = "Version metadata URL"; +"app_language.english" = "English"; +"app_language.simplified_chinese" = "简体中文"; +"app_language.system" = "系统默认"; +"app_update.state.checking" = "正在检查更新"; +"app_update.state.current" = "TimeCapsuleSMB 已是最新版本。"; +"app_update.state.failed" = "更新检查失败。"; +"app_update.state.idle" = "尚未检查。"; +"app_update.state.unavailable" = "Version metadata 不可用。"; +"app_update.state.update_available" = "有可用更新。"; +"button.activate" = "Activate"; +"button.capabilities" = "Capabilities"; +"button.configure" = "Configure"; +"button.deploy" = "Deploy"; +"button.discover" = "Discover"; +"button.list_fsck_volumes" = "列出 fsck Volumes"; +"button.paths" = "Paths"; +"button.plan_deploy" = "Plan Deploy"; +"button.plan_fsck" = "Plan fsck"; +"button.repair_xattrs" = "修复 xattrs"; +"button.run_doctor" = "运行 Doctor"; +"button.run_fsck" = "运行 fsck"; +"button.scan_xattrs" = "扫描 xattrs"; +"button.uninstall" = "卸载"; +"button.uninstall_plan" = "Uninstall Plan"; +"button.validate" = "Validate"; +"checkup.presentation.headline.failed" = "检查失败。"; +"checkup.presentation.headline.idle" = "运行检查来检查此 Time Capsule。"; +"checkup.presentation.headline.passed" = "检查通过。"; +"checkup.presentation.headline.run_failed" = "检查无法完成。"; +"checkup.presentation.headline.running" = "正在运行检查。"; +"checkup.presentation.headline.warning" = "检查发现警告。"; +"checkup.progress.running.message" = "正在运行本地和远程诊断检查。\n这可能需要几分钟..."; +"checkup.progress.running.title" = "正在运行检查"; +"checkup.presentation.row.fail" = "Fail"; +"checkup.presentation.row.info" = "Info"; +"checkup.presentation.row.pass" = "Pass"; +"checkup.presentation.row.warning" = "Warning"; +"close_guard.close_anyway" = "仍然关闭"; +"close_guard.keep_open" = "保持打开"; +"close_guard.message" = "TimeCapsuleSMB 有操作正在进行。现在关闭可能会中断 Time Capsule 上的工作。"; +"close_guard.title" = "关闭 TimeCapsuleSMB?"; +"confirm.activate.netbsd4.action" = "Activate"; +"confirm.activate.netbsd4.message" = "Activate 已部署的 NetBSD4 payload 并重启 managed services?"; +"confirm.activate.netbsd4.title" = "Activate NetBSD4 Runtime?"; +"confirm.backend.message" = "继续此操作?"; +"confirm.backend.title" = "确认操作?"; +"confirm.configure.enable_ssh_reboot.action" = "启用 SSH 并重启"; +"confirm.configure.enable_ssh_reboot.message" = "%@ 上的 SSH 已关闭。是否使用 AirPort ACP 启用 SSH 并重启此 Time Capsule?"; +"confirm.configure.enable_ssh_reboot.title" = "启用 SSH 并重启?"; +"confirm.deploy.activate_now.action" = "Deploy 并启动 SMB"; +"confirm.deploy.activate_now.message" = "将 TimeCapsuleSMB Deploy 到此 %@ 并在不重启的情况下启动 SMB?"; +"confirm.deploy.activate_now.title" = "Deploy 并启动 SMB?"; +"confirm.deploy.netbsd4.action" = "Deploy、重启并 Activate"; +"confirm.deploy.netbsd4.message" = "将 TimeCapsuleSMB Deploy 到此 %@,重启它,然后在 SSH 恢复后 Activate Samba?"; +"confirm.deploy.netbsd4.title" = "Deploy、重启并 Activate NetBSD4?"; +"confirm.deploy.netbsd4_no_wait.action" = "Deploy 并请求重启"; +"confirm.deploy.netbsd4_no_wait.message" = "将 TimeCapsuleSMB Deploy 到此 %@,请求重启,并立即返回,不在 SSH 恢复后运行 Samba activation?"; +"confirm.deploy.netbsd4_no_wait.title" = "Deploy 并请求 NetBSD4 重启?"; +"confirm.deploy.no_reboot.action" = "Deploy"; +"confirm.deploy.no_reboot.message" = "将 TimeCapsuleSMB Deploy 到此 %@,但不重启?"; +"confirm.deploy.no_reboot.title" = "Deploy 但不重启?"; +"confirm.deploy.reboot.action" = "Deploy 并重启"; +"confirm.deploy.reboot.message" = "Deploy TimeCapsuleSMB 并重启此 %@?"; +"confirm.deploy.reboot.title" = "Deploy 并重启?"; +"confirm.deploy.reboot_no_wait.action" = "Deploy 并请求重启"; +"confirm.deploy.reboot_no_wait.message" = "将 TimeCapsuleSMB Deploy 到此 %@,请求重启,并立即返回?"; +"confirm.deploy.reboot_no_wait.title" = "Deploy 并请求重启?"; +"confirm.fsck.no_reboot.action" = "运行 fsck"; +"confirm.fsck.no_reboot.message" = "在选定的 HFS volume 上运行 fsck?"; +"confirm.fsck.no_reboot.title" = "运行 Disk Repair?"; +"confirm.fsck.reboot.action" = "运行 fsck"; +"confirm.fsck.reboot.message" = "在选定的 HFS volume 上运行 fsck 并重启设备?"; +"confirm.fsck.reboot.title" = "运行 Disk Repair 并重启?"; +"confirm.flash.patch_write.action" = "写入 Firmware"; +"confirm.flash.patch_write.message" = "Patch %@ 上 primary firmware bank 的 Boot Hook?成功写入后需要手动断电重启。"; +"confirm.flash.patch_write.title" = "Patch Firmware Boot Hook?"; +"confirm.flash.restore_write.action" = "写入 Firmware"; +"confirm.flash.restore_write.message" = "将 %@ 上的 active firmware bank 恢复为 Apple stock firmware?"; +"confirm.flash.restore_write.title" = "恢复 Apple Firmware?"; +"confirm.repair_xattrs.action" = "修复 xattrs"; +"confirm.repair_xattrs.message" = "修复 %@ 下已知安全的 macOS metadata 问题?"; +"confirm.repair_xattrs.title" = "修复 Extended Attributes?"; +"confirm.uninstall.no_reboot.action" = "卸载"; +"confirm.uninstall.no_reboot.message" = "从设备中移除 managed TimeCapsuleSMB files?"; +"confirm.uninstall.no_reboot.title" = "卸载?"; +"confirm.uninstall.reboot.action" = "卸载"; +"confirm.uninstall.reboot.message" = "从设备中移除 managed TimeCapsuleSMB files 并重启?"; +"confirm.uninstall.reboot.title" = "卸载并重启?"; +"dashboard.action.install_smb" = "安装 SMB"; +"dashboard.action.install_update_smb" = "安装 / 更新 SMB"; +"dashboard.action.refresh_status" = "刷新状态"; +"dashboard.action.settings" = "设置"; +"dashboard.action.open_finder" = "打开 Finder"; +"dashboard.action.open_smb" = "打开 Finder"; +"dashboard.action.replace_password" = "替换密码"; +"dashboard.action.run_checkup" = "运行检查"; +"dashboard.action.start_smb" = "Activate"; +"dashboard.action.view_checkup" = "查看检查"; +"dashboard.header.last_checked" = "上次检查"; +"dashboard.health.check_counts" = "PASS %d,WARN %d,FAIL %d"; +"dashboard.health.connection" = "连接"; +"dashboard.health.connection.keychain_unavailable" = "无法从 Keychain 读取保存的密码。"; +"dashboard.health.connection.not_refreshed" = "连接状态尚未刷新。"; +"dashboard.health.connection.password_invalid" = "保存的密码被 Time Capsule 拒绝。"; +"dashboard.health.connection.refreshing" = "正在检查 DNS、SSH 和 SMB reachability..."; +"dashboard.health.connection.running" = "Backend operation 正在使用此 Profile。"; +"dashboard.health.checkup" = "检查"; +"dashboard.health.finder_bonjour" = "Finder / Bonjour"; +"dashboard.health.runtime" = "Runtime"; +"dashboard.health.runtime.activation_needed" = "此 NetBSD4 设备重启后可能需要 Activate。"; +"dashboard.health.runtime.installing" = "Install / Update 正在运行。"; +"dashboard.health.runtime.not_installed" = "尚未从此 App 安装 SMB。"; +"dashboard.health.smb_auth" = "SMB Auth"; +"dashboard.health.status.failed" = "失败"; +"dashboard.health.status.good" = "良好"; +"dashboard.health.status.running" = "运行中"; +"dashboard.health.status.unknown" = "未知"; +"dashboard.health.status.warning" = "警告"; +"dashboard.health.time_machine" = "Time Machine"; +"dashboard.health.unchecked" = "运行检查以检查此区域。"; +"dashboard.overview.generation" = "Generation"; +"dashboard.overview.addresses" = "Addresses"; +"dashboard.overview.connection_target" = "Connection Target"; +"dashboard.overview.host" = "Connection Target"; +"dashboard.overview.last_checkup" = "上次检查"; +"dashboard.overview.last_install" = "上次安装"; +"dashboard.overview.model" = "Model"; +"dashboard.overview.password" = "密码"; +"dashboard.overview.payload" = "Payload"; +"dashboard.overview.status" = "状态"; +"dashboard.password.title" = "已保存密码"; +"dashboard.replacement_password" = "更新已保存密码"; +"dashboard.tab.settings" = "设置"; +"dashboard.tab.checkup" = "检查"; +"dashboard.tab.install" = "安装 / 更新"; +"dashboard.tab.maintenance" = "维护"; +"dashboard.tab.overview" = "概览"; +"deploy.action.plan_install" = "Plan Install"; +"deploy.advanced_plan_details" = "Advanced Plan Details"; +"deploy.presentation.expected_changes" = "%d 个文件上传,%d 个安装动作"; +"deploy.presentation.row.activation_actions" = "Activation Actions"; +"deploy.presentation.row.disk_location" = "Disk Location"; +"deploy.presentation.row.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" = "重启"; +"deploy.presentation.row.target" = "Target"; +"deploy.presentation.title.netbsd4" = "安装 SMB 并启动 Runtime"; +"deploy.presentation.title.standard" = "安装 SMB"; +"deploy.presentation.warning.netbsd4_activation" = "此 NetBSD4 设备需要 activation step 后 Samba 才能就绪。"; +"deploy.presentation.warning.netbsd4_activate_now" = "此 NetBSD4 安装会在不重启的情况下启动 Samba。"; +"deploy.presentation.warning.netbsd4_reboot_then_activate" = "此 NetBSD4 安装会先重启,然后在 SSH 恢复后启动 Samba。"; +"deploy.presentation.warning.no_wait_post_reboot_activation" = "No Wait 会在请求重启后返回。SSH 恢复后不会自动运行 Samba activation。"; +"deploy.presentation.warning.no_wait_post_reboot_verification" = "No Wait 会在请求重启后返回。不会自动运行 post-reboot SMB verification。"; +"deploy.result.default_message" = "安装已完成。"; +"deploy.result.message" = "Message"; +"deploy.result.reboot_requested" = "已请求重启"; +"deploy.result.verified" = "已验证"; +"install.action.create_plan" = "创建安装计划"; +"install.action.install_update" = "安装 / 更新"; +"install.action.reinstall" = "重新安装"; +"install.action.regenerate_plan" = "重新生成计划"; +"install.advanced_options" = "Advanced Options"; +"install.completion.title.finished" = "安装 / 更新已完成"; +"install.completion.title.verified" = "安装 / 更新已验证"; +"install.completion.warning.netbsd4" = "NetBSD4 设备在之后重启后可能需要 Activate,除非 Boot Hook 已 patch。"; +"install.plan.downtime.activate_now" = "Samba 不重启启动时通常不到一分钟。"; +"install.plan.downtime.netbsd4" = "Samba 不重启启动时通常不到一分钟。"; +"install.plan.downtime.no_wait" = "App 会请求重启并立即返回。"; +"install.plan.downtime.none" = "预计无需重启。"; +"install.plan.downtime.reboot" = "Time Capsule 重启时需要几分钟。"; +"install.plan.row.disk" = "Disk"; +"install.plan.row.expected_downtime" = "预计停机时间"; +"install.plan.row.remote_actions" = "Remote Actions"; +"install.plan.row.uploads" = "Uploads"; +"install.plan.section.device_actions" = "Device Actions"; +"install.plan.section.files" = "Files"; +"install.plan.section.target" = "Target"; +"install.plan.title.activate_now" = "安装 / 更新 SMB 并启动 Runtime"; +"install.plan.title.netbsd4" = "安装 / 更新 SMB 并启动 Runtime"; +"install.plan.title.reboot_no_wait" = "安装 / 更新 SMB 并请求重启"; +"install.plan.title.reboot_then_activate" = "安装 / 更新 SMB、重启并启动 Runtime"; +"install.plan.title.standard" = "安装 / 更新 SMB"; +"install.advanced_options.no_wait_note" = "No Wait 是 power-user 选项:它会请求重启并立即返回,不进行 post-reboot activation 或 verification。"; +"install.progress.deploying.message" = "正在上传并应用 managed SMB Runtime。这可能需要几分钟..."; +"install.progress.deploying.title" = "正在安装 / 更新 SMB"; +"install.state.awaiting_confirmation" = "继续前请查看确认对话框。"; +"install.state.deploy_failed" = "安装 / 更新失败。"; +"install.state.deployed" = "安装 / 更新已完成。"; +"install.state.deploying" = "正在安装文件并应用设备更改。"; +"install.state.idle" = "安装或更新 SMB 前请先创建计划。"; +"install.state.plan_failed" = "无法创建安装计划。"; +"install.state.plan_ready" = "查看计划,然后运行安装 / 更新。"; +"install.state.plan_stale" = "Advanced options 在此计划创建后发生了变化。"; +"install.state.planning" = "正在创建安装计划。"; +"install.timeline.title" = "进度"; +"install.timeline.waiting" = "正在等待 Backend progress。"; +"install.warning.awaiting_confirmation" = "Backend 正在等待明确确认。"; +"install.warning.plan_stale" = "安装前请重新生成计划。"; +"diagnostics.backend_events" = "Backend Events"; +"diagnostics.copied" = "Diagnostics 已复制。"; +"diagnostics.copy" = "复制 Diagnostics"; +"diagnostics.distribution" = "Distribution"; +"diagnostics.helper" = "Helper"; +"diagnostics.runtime_issues" = "Runtime Issues"; +"diagnostics.save" = "保存 Diagnostics..."; +"diagnostics.saved" = "Diagnostics 已保存。"; +"diagnostics.state" = "State"; +"diagnostics.title" = "Diagnostics"; +"diagnostics.validation" = "Validation"; +"dialog.forget.action" = "忘记 %@"; +"dialog.forget.error_title" = "无法忘记 Time Capsule"; +"dialog.forget.message" = "从此 Mac 移除 %@?这不会从 Time Capsule 卸载 SMB。"; +"dialog.forget.title" = "忘记 Time Capsule?"; +"doctor.domain.connection" = "连接"; +"doctor.domain.disk" = "Disk"; +"doctor.domain.finder_bonjour" = "Finder / Bonjour"; +"doctor.domain.general" = "常规"; +"doctor.domain.metadata" = "Metadata"; +"doctor.domain.runtime" = "Runtime"; +"doctor.domain.smb_auth" = "SMB Auth"; +"doctor.domain.time_machine" = "Time Machine"; +"event.summary.check" = "%@ %@"; +"event.summary.check.default_status" = "INFO"; +"event.summary.error" = "%@:%@"; +"event.summary.error.default_message" = "error"; +"event.summary.result" = "%@:%@"; +"event.summary.result.failed" = "failed"; +"event.summary.result.finished" = "finished"; +"event.summary.stage" = "%@:%@"; +"field.ata_idle_seconds" = "ATA idle seconds"; +"field.ata_standby" = "ATA standby seconds"; +"field.bonjour_timeout" = "Bonjour timeout seconds"; +"field.fsck_volume" = "fsck volume,可选"; +"field.firmware_template" = "Firmware template path,可选"; +"field.firmware_version" = "Firmware version,可选"; +"field.helper" = "Helper"; +"field.host" = "Host"; +"field.mount_wait" = "Mount wait seconds"; +"field.password" = "密码"; +"field.repair_xattrs_max_depth" = "Max depth"; +"field.repair_xattrs_path" = "Repair xattrs path"; +"flash.action.backup_inspect" = "备份并检查"; +"flash.action.backup_inspect_again" = "再次备份并检查"; +"flash.action.check_apple" = "检查 Apple Firmware"; +"flash.action.choose_template" = "选择"; +"flash.action.download_apple" = "验证 Apple Restore Firmware"; +"flash.action.plan_patch" = "Plan Patch"; +"flash.action.plan_restore" = "Plan Restore"; +"flash.action.write_patch" = "Write Patch"; +"flash.action.write_restore" = "Write Restore"; +"flash.manual_power_cycle.message" = "Flash write validation 已完成。拔掉 Time Capsule 电源,等待 10 秒,然后重新插上。等它完成启动后运行检查。一个 firmware bank 未被修改。"; +"flash.manual_power_cycle.title" = "需要手动重启"; +"flash.mode.check_apple" = "检查 Apple Firmware"; +"flash.mode.download_only" = "验证 Apple Restore Firmware"; +"flash.mode.patch" = "Patch Boot Hook"; +"flash.mode.restore" = "恢复 Apple Firmware"; +"flash.options.apple_firmware" = "Apple Firmware Options"; +"flash.row.active_bank" = "Active Bank"; +"flash.row.apple_match" = "Apple Match"; +"flash.row.apple_payload_sha256" = "Apple Payload SHA-256"; +"flash.row.apple_product" = "Apple Product"; +"flash.row.apple_source" = "Apple Source"; +"flash.row.apple_version" = "Apple Version"; +"flash.row.backup_dir" = "Backup"; +"flash.row.banks" = "Banks"; +"flash.row.firmware_payload_path" = "Firmware Payload"; +"flash.row.firmware_payload_sha256" = "Firmware Payload SHA-256"; +"flash.row.firmware_payload_size" = "Firmware Payload Size"; +"flash.row.firmware_product" = "Firmware Product"; +"flash.row.firmware_source" = "Firmware Source"; +"flash.row.firmware_version" = "Firmware Version"; +"flash.row.mode" = "Mode"; +"flash.row.write_requested" = "Write Requested"; +"flash.row.write_status" = "Write Status"; +"flash.row.write_validated" = "Write Validated"; +"flash.title" = "Persistent NetBSD4 Boot Hook"; +"flash.warning.manual_power_cycle" = "拔掉 Time Capsule 电源,等待 10 秒,然后重新插上。"; +"flash.warning.snapshot_stale" = "此 backup 后已写入 Firmware。计划另一个 flash action 前,请再次备份并检查。"; +"helper.error.cancelled" = "操作已取消。"; +"helper.error.missing_terminal_event" = "Helper 退出时没有 result 或 error event。"; +"host_warning.time_machine.message" = "macOS %d.%d.%d 存在已知的 Time Machine network backup 问题。SMB 可能可用,但 backup 可靠性可能受 Host OS 影响。"; +"host_warning.time_machine.title" = "macOS Time Machine 警告"; +"activity.app_ready" = "App 就绪"; +"activity.active" = "Active"; +"activity.last_operation" = "上次操作"; +"activity.multiple_active" = "%d 个 active operations"; +"activity.multiple_active.message" = "打开 Activity 查看详情。"; +"activity.no_active_operation" = "没有 active operation"; +"activity.one_active" = "1 个 active operation"; +"activity.recent" = "Recent"; +"activity.timeline" = "Timeline"; +"activity.timeline.empty" = "还没有 operation history。"; +"discovery_monitor.last_seen.now" = "刚刚看到"; +"discovery_monitor.state.discovering" = "正在发现"; +"discovery_monitor.state.empty" = "未找到设备"; +"discovery_monitor.state.failed" = "Discovery 失败"; +"discovery_monitor.state.idle" = "空闲"; +"discovery_monitor.state.paused" = "已暂停"; +"discovery_monitor.state.readiness_blocked" = "App 被阻止"; +"discovery_monitor.state.ready" = "已找到设备"; +"discovery_monitor.state.waiting_for_readiness" = "正在等待 App Readiness"; +"checkup.advanced_options" = "Advanced Options"; +"checkup.option.skip_bonjour" = "跳过 Bonjour 检查"; +"checkup.option.skip_smb" = "跳过 SMB 检查"; +"checkup.option.skip_ssh" = "跳过 SSH 检查"; +"checkup.status.failed" = "失败"; +"checkup.status.info" = "Info"; +"checkup.status.passed" = "通过"; +"checkup.status.unknown" = "未知"; +"checkup.status.warning" = "警告"; +"checkup.timeline.title" = "进度"; +"maintenance.action.choose" = "选择"; +"maintenance.action.choose_folder" = "选择文件夹"; +"maintenance.action.find_volumes" = "查找 Volumes"; +"maintenance.action.plan_disk_repair" = "Plan Disk Repair"; +"maintenance.action.plan_start_smb" = "Plan Activate"; +"maintenance.action.plan_uninstall" = "Plan Uninstall"; +"maintenance.action.repair_metadata" = "修复 Metadata"; +"maintenance.action.run_disk_repair" = "运行 Disk Repair"; +"maintenance.action.scan_metadata" = "扫描 Metadata"; +"maintenance.action.start_smb" = "Activate"; +"maintenance.action.uninstall" = "卸载"; +"maintenance.advanced_options" = "Advanced Options"; +"maintenance.presentation.activate.primary_action" = "Activate"; +"maintenance.presentation.activate.subtitle" = "在 NetBSD4 Time Capsule 上启动已部署的 SMB Runtime。"; +"maintenance.presentation.activate.title" = "NetBSD4 Activation"; +"maintenance.presentation.fsck.primary_action" = "运行 Disk Repair"; +"maintenance.presentation.fsck.subtitle" = "Unmount 选定的 HFS volume,并在设备上运行 fsck_hfs。"; +"maintenance.presentation.fsck.title" = "Disk Repair"; +"maintenance.presentation.repair_xattrs.primary_action" = "修复 Metadata"; +"maintenance.presentation.repair_xattrs.subtitle" = "扫描并修复 mounted SMB share 上的 macOS metadata。"; +"maintenance.presentation.repair_xattrs.title" = "File Metadata Repair"; +"maintenance.presentation.risk.destructive" = "破坏性"; +"maintenance.presentation.risk.local_destructive" = "本地破坏性"; +"maintenance.presentation.risk.remote_write" = "Remote write"; +"maintenance.presentation.uninstall.primary_action" = "卸载"; +"maintenance.presentation.uninstall.subtitle" = "从选定 Time Capsule 移除 managed SMB files。"; +"maintenance.presentation.uninstall.title" = "卸载"; +"maintenance.completion.activate" = "Activation 完成"; +"maintenance.completion.fsck" = "Disk Repair 完成"; +"maintenance.completion.repair_xattrs" = "Metadata Repair 完成"; +"maintenance.completion.uninstall" = "卸载完成"; +"maintenance.fsck.no_volumes" = "Planning disk repair 前请先查找 mounted volumes。"; +"maintenance.plan.activate" = "Activation Plan"; +"maintenance.plan.fsck" = "Disk Repair Plan"; +"maintenance.plan.repair_xattrs" = "Metadata Scan"; +"maintenance.plan.row.actions" = "Actions"; +"maintenance.plan.row.device" = "Device"; +"maintenance.plan.row.findings" = "Findings"; +"maintenance.plan.row.host" = "Host"; +"maintenance.plan.row.mountpoint" = "Mountpoint"; +"maintenance.plan.row.path" = "Path"; +"maintenance.plan.row.payload_dirs" = "Payload Directories"; +"maintenance.plan.row.post_checks" = "Post-checks"; +"maintenance.plan.row.reboot" = "重启"; +"maintenance.plan.row.remote_actions" = "Remote Actions"; +"maintenance.plan.row.repairable" = "Repairable"; +"maintenance.plan.row.wait_after_reboot" = "Wait After Reboot"; +"maintenance.plan.uninstall" = "Uninstall Plan"; +"maintenance.result.already_active" = "已 Active"; +"maintenance.result.returncode" = "Return Code"; +"maintenance.state.awaiting_confirmation" = "继续前请查看确认对话框。"; +"maintenance.state.failed" = "维护失败。"; +"maintenance.state.fsck_list_ready" = "选择一个 volume,然后 plan disk repair。"; +"maintenance.state.idle" = "选择下一个维护动作。"; +"maintenance.state.loading" = "正在查找 mounted volumes。"; +"maintenance.state.plan_ready" = "运行此维护动作前请查看计划。"; +"maintenance.state.plan_stale" = "Options 在此计划创建后发生了变化。"; +"maintenance.state.planning" = "正在创建维护计划。"; +"maintenance.state.running" = "维护正在运行。"; +"maintenance.state.scan_ready" = "Repair metadata 前请查看 scan。"; +"maintenance.state.scan_stale" = "此 scan 后 path 已发生变化。"; +"maintenance.state.scanning" = "正在扫描 Metadata。"; +"maintenance.state.succeeded" = "维护已完成。"; +"maintenance.timeline.title" = "进度"; +"maintenance.warning.destructive_fsck" = "Disk repair 可能修改选定的 Time Capsule volume。"; +"maintenance.warning.destructive_uninstall" = "卸载会从此 Time Capsule 移除 managed SMB files。"; +"maintenance.warning.local_metadata_repair" = "Metadata repair 会修改选定本地 SMB mount 下的文件。"; +"maintenance.repairable_count" = "%d 个 repairable items"; +"maintenance.workflow.activate" = "NetBSD4 Activation"; +"maintenance.workflow.fsck" = "Disk Repair"; +"maintenance.workflow.repair_xattrs" = "File Metadata Repair"; +"maintenance.workflow.uninstall" = "卸载"; +"overview.empty.message" = "添加 Time Capsule 以配置 SMB、运行检查并管理维护任务。"; +"overview.empty.title" = "没有保存的 Time Capsule"; +"overview.discovery.add" = "添加"; +"overview.discovery.discovering" = "正在查找 Time Capsule..."; +"overview.discovery.empty" = "未找到附近的 Time Capsule。"; +"overview.discovery.failed" = "Discovery 失败。"; +"overview.discovery.paused" = "当前操作完成后 Discovery 会恢复。"; +"overview.discovery.readiness_blocked" = "修复 App Readiness 前 Discovery 不可用。"; +"overview.discovery.refresh" = "刷新"; +"overview.discovery.saved" = "已保存"; +"overview.discovery.title" = "附近的 Time Capsule"; +"overview.discovery.unsaved" = "未保存"; +"overview.discovery.waiting" = "App Runtime 就绪后会开始 Discovery。"; +"operation.error.already_running" = "已有另一个操作正在运行。"; +"panel.connect" = "Discover And Connect"; +"password_state.available" = "可用"; +"password_state.invalid" = "无效"; +"password_state.keychain_unavailable" = "Keychain 不可用"; +"password_state.missing" = "缺失"; +"password_state.unknown" = "未知"; +"password.error.keychain_status" = "Keychain error %d。"; +"password.error.memory_delete_failed" = "In-memory password store delete 失败。"; +"password.error.memory_read_failed" = "In-memory password store read 失败。"; +"password.error.memory_save_failed" = "In-memory password store save 失败。"; +"password.error.missing" = "密码缺失。"; +"password.error.required" = "需要密码。"; +"password.error.unreadable_keychain_item" = "Keychain 返回了不可读取的密码。"; +"profile_editor.advanced" = "Advanced"; +"profile_editor.advanced.deploy_notice" = "请执行 Deploy,将这些设置更新到你的设备"; +"profile_editor.display_name" = "Display Name"; +"profile_editor.error.duplicate_host" = "另一个保存的 Time Capsule 已使用此 Host。"; +"profile_editor.error.host_required" = "Host 为必填。"; +"profile_editor.error.ata_idle_seconds_invalid" = "ATA idle seconds 必须是非负整数。"; +"profile_editor.error.ata_standby_invalid" = "ATA standby seconds 必须留空或为非负整数。"; +"profile_editor.error.mount_wait_invalid" = "Mount wait 必须是非负整数。"; +"profile_editor.error.password_required" = "更改 Host 需要保存的密码。"; +"profile_editor.reset" = "重置"; +"profile_editor.save" = "保存 Profile"; +"profile_editor.state.auth_failed" = "密码被拒绝"; +"profile_editor.state.clean" = "已保存"; +"profile_editor.state.dirty" = "未保存的更改"; +"profile_editor.state.failed" = "失败"; +"profile_editor.state.invalid" = "需要更改"; +"profile_editor.state.reconfiguring" = "正在 Reconfigure"; +"profile_editor.state.saved" = "已保存"; +"profile_editor.state.saving" = "正在保存"; +"profile_editor.state.unsupported" = "不支持"; +"profile_editor.title" = "Device Profile"; +"readiness.blocked.title" = "TimeCapsuleSMB 无法启动"; +"readiness.state.checking_capabilities" = "正在检查 Helper"; +"readiness.state.checking_version" = "正在检查版本"; +"readiness.state.resolving_bundle" = "正在准备 App Runtime"; +"readiness.state.validating_install" = "正在验证 bundled files"; +"readiness.warning.default" = "TimeCapsuleSMB 正在带警告运行。"; +"recovery.action.copy_diagnostics" = "复制 Diagnostics"; +"recovery.action.disk_repair" = "运行 Disk Repair"; +"recovery.action.install_smb" = "安装 SMB"; +"recovery.action.metadata_repair" = "修复 File Metadata"; +"recovery.action.open" = "打开"; +"recovery.action.open_diagnostics" = "打开 Diagnostics"; +"recovery.action.open_finder" = "打开 Finder"; +"recovery.action.replace_password" = "替换密码"; +"recovery.action.retry" = "重试"; +"recovery.action.run_checkup" = "运行检查"; +"recovery.action.start_smb" = "Activate"; +"recovery.action.uninstall" = "卸载"; +"screen.settings" = "设置"; +"screen.connect" = "Connect"; +"screen.deploy" = "Deploy"; +"screen.doctor" = "Doctor"; +"screen.maintenance" = "Maintenance"; +"screen.readiness" = "Readiness"; +"sidebar.add_time_capsule" = "添加 Time Capsule"; +"sidebar.all_time_capsules" = "所有 Time Capsule"; +"sidebar.activity" = "Activity"; +"sidebar.devices" = "设备"; +"sidebar.settings" = "设置"; +"status.activation_needed" = "需要 Activation"; +"status.checking" = "正在检查"; +"status.failed" = "失败"; +"status.healthy" = "健康"; +"status.installing" = "正在安装"; +"status.keychain_unavailable" = "Keychain 不可用"; +"status.maintenance" = "维护"; +"status.offline" = "离线"; +"status.password_invalid" = "密码无效"; +"status.password_needed" = "需要密码"; +"status.ready_to_install" = "可以安装"; +"status.removed" = "已移除"; +"status.unchecked" = "未检查"; +"status.unsupported" = "不支持"; +"status.warning" = "警告"; +"summary.checkup_counts" = "PASS %d,WARN %d,FAIL %d"; +"timeline.error.needs_attention" = "需要注意"; +"timeline.error.needs_confirmation" = "需要确认"; +"timeline.operation.activate" = "Activate"; +"timeline.operation.configure" = "添加 Time Capsule"; +"timeline.operation.deploy" = "安装 / 更新"; +"timeline.operation.discovery" = "Discovery"; +"timeline.operation.doctor" = "检查"; +"timeline.operation.flash" = "Persistent NetBSD4 Boot Hook"; +"timeline.operation.fsck" = "Disk Repair"; +"timeline.operation.reachability" = "Reachability"; +"timeline.operation.readiness" = "App Readiness"; +"timeline.operation.repair_xattrs" = "File Metadata Repair"; +"timeline.operation.telemetry" = "Telemetry Settings"; +"timeline.operation.uninstall" = "卸载"; +"timeline.operation.version_check" = "Update Check"; +"timeline.result.done" = "完成"; +"timeline.result.failed" = "失败"; +"timeline.stage.checking_bundled_files" = "正在检查 Bundled Files"; +"timeline.stage.checking_runtime" = "正在检查 SMB"; +"timeline.stage.checking_ssh" = "正在检查 SSH"; +"timeline.stage.confirming_ssh_enable" = "正在确认启用 SSH"; +"timeline.stage.deleting_old_deployed_files" = "正在删除旧 deployed files"; +"timeline.stage.enabling_ssh" = "正在启用 SSH"; +"timeline.stage.finding_disk" = "正在查找 Disk"; +"timeline.stage.finding_time_capsules" = "正在查找 Time Capsule"; +"timeline.stage.finding_volumes" = "正在查找 Volumes"; +"timeline.stage.planning_install" = "正在 Planning Install"; +"timeline.stage.planning_start_smb" = "正在 Planning Activation"; +"timeline.stage.planning_uninstall" = "正在 Planning Uninstall"; +"timeline.stage.reachability_candidates" = "正在准备 Reachability Check"; +"timeline.stage.reachability_dns" = "正在检查 DNS"; +"timeline.stage.reachability_ping" = "正在检查 Ping"; +"timeline.stage.reachability_smb_port" = "正在检查 SMB Port"; +"timeline.stage.reachability_ssh_auth" = "正在检查 SSH Auth"; +"timeline.stage.reachability_ssh_port" = "正在检查 SSH Port"; +"timeline.stage.rebooting" = "正在重启"; +"timeline.stage.removing_managed_files" = "正在移除 Managed Files"; +"timeline.stage.repairing_disk" = "正在修复 Disk"; +"timeline.stage.repairing_metadata" = "正在修复 Metadata"; +"timeline.stage.running_checkup" = "正在运行检查"; +"timeline.stage.saving_device" = "正在保存设备"; +"timeline.stage.scanning_metadata" = "正在扫描 Metadata"; +"timeline.stage.starting_smb" = "正在启动 SMB"; +"timeline.stage.syncing_to_disk" = "正在 Sync 到 Disk"; +"timeline.stage.uploading" = "正在上传"; +"timeline.stage.validating_app_bundle" = "正在验证 App Bundle"; +"timeline.stage.verifying_smb" = "正在验证 SMB"; +"timeline.stage.waiting_for_device" = "正在等待设备"; +"toggle.dry_run" = "Dry Run"; +"toggle.enable_debug_logging" = "Enable Debug Logging"; +"toggle.enable_nbns" = "Enable NBNS"; +"toggle.internal_share_use_disk_root" = "Internal Share Uses Disk Root"; +"toggle.any_protocol" = "Allow Any SMB Protocol"; +"toggle.force_debug_logging" = "Force Debug Logging"; +"toggle.no_reboot" = "No Reboot"; +"toggle.no_wait" = "No Wait"; +"toggle.repair_xattrs_fix_permissions" = "Fix Permissions"; +"toggle.repair_xattrs_include_hidden" = "Include Hidden Paths"; +"toggle.repair_xattrs_include_time_machine" = "Include Time Machine Paths"; +"toggle.repair_xattrs_recursive" = "Recursive"; +"toggle.repair_xattrs_verbose" = "Verbose Output"; +"toolbar.cancel" = "取消"; +"toolbar.add" = "添加"; +"toolbar.clear" = "清除"; +"toolbar.diagnostics" = "Diagnostics"; +"toolbar.disabled" = "已禁用"; +"toolbar.forget" = "忘记"; +"value.auto" = "Auto"; +"value.never" = "Never"; +"value.no" = "no"; +"value.not_required" = "Not required"; +"value.required" = "Required"; +"value.unknown" = "Unknown"; +"value.yes" = "yes"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift index 8b3cd35e..81dac85d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift @@ -60,6 +60,9 @@ struct DeviceDashboardView: View { isPresented: manualPowerCycleNoticePresented, presenting: session.flashStore.manualPowerCycleNotice ) { notice in + Button(notice.viewCheckupActionTitle) { + session.viewCheckupAfterFlashNotice() + } Button(notice.actionTitle, role: .cancel) { session.flashStore.dismissManualPowerCycleNotice() } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift index fd662fff..92e15009 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift @@ -15,7 +15,9 @@ struct OverviewTab: View { let summary = session.summary(for: profile) let presentation = DeviceDashboardOverviewPresentation( summary: summary, - currentCheckupSummary: session.doctorStore.summary + currentCheckupSummary: session.doctorStore.summary, + reachabilitySnapshot: appStore.reachabilityStore.snapshot(for: profile), + isReachabilityRunning: appStore.reachabilityStore.isRunning(profile: profile) ) ScrollView { @@ -39,15 +41,6 @@ struct OverviewTab: View { } ) - if presentation.requiresPasswordReplacement || session.isReplacingPassword { - PasswordReplacementView(profile: profile, session: session) - } - - if let passwordError = session.passwordError { - Text(passwordError) - .foregroundStyle(.red) - } - VStack(alignment: .leading, spacing: 10) { ForEach(presentation.healthSections) { section in DashboardHealthSectionView(section: section, isActionEnabled: presentation.isEnabled) { action in @@ -162,34 +155,6 @@ private struct DashboardActionLabel: View { } } -private struct PasswordReplacementView: View { - let profile: DeviceProfile - @ObservedObject var session: DeviceDashboardSession - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.string("dashboard.password.title")) - .font(.headline) - HStack { - SecureField(L10n.string("dashboard.replacement_password"), text: $session.replacementPassword) - .onSubmit { - Task { @MainActor in - await session.saveReplacementPassword(for: profile) - } - } - Button { - Task { @MainActor in - await session.saveReplacementPassword(for: profile) - } - } label: { - Label(L10n.string("dashboard.action.save_password"), systemImage: "key") - } - .disabled(session.replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } -} - private struct DashboardHealthSectionView: View { let section: DashboardHealthSection let isActionEnabled: (DashboardSecondaryAction) -> Bool diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift index fb31f59b..2d4e217b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift @@ -49,6 +49,24 @@ private struct DeviceProfileEditorView: View { TextField(L10n.string("dashboard.overview.host"), text: $store.draft.host) .frame(maxWidth: 360) } + GridRow { + Text(L10n.string("dashboard.password.title")) + .foregroundStyle(.secondary) + SecureField(L10n.string("dashboard.replacement_password"), text: $store.replacementPassword) + .frame(maxWidth: 360) + .onSubmit { + guard store.canSave else { return } + Task { @MainActor in + await store.save(profile: profile) + } + } + } + } + + if let passwordError = store.passwordError { + Text(passwordError) + .font(.caption) + .foregroundStyle(.red) } DeviceProfileAdvancedSettingsView(store: store) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift index d7e6a3c2..3fffb629 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift @@ -12,6 +12,19 @@ struct AppSettingsView: View { header .frame(maxWidth: contentWidth, alignment: .leading) + SettingsFormSection(title: L10n.string("app_settings.section.general"), contentWidth: contentWidth) { + SettingsFormRow(title: L10n.string("app_settings.language")) { + Picker("", selection: $editor.draft.language) { + ForEach(AppLanguage.allCases) { language in + Text(language.title) + .tag(language) + } + } + .labelsHidden() + .frame(width: 220) + } + } + SettingsFormSection(title: L10n.string("app_settings.section.defaults"), contentWidth: contentWidth) { SettingsFormRow(title: L10n.string("app_settings.default_bonjour_timeout")) { TextField("", text: $editor.draft.defaultBonjourTimeoutSeconds) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift index ed3f7626..5d1adab3 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift @@ -1,6 +1,7 @@ import Foundation enum DashboardSecondaryAction: String, CaseIterable, Equatable, Hashable, Identifiable { + case refreshStatus case runCheckup case installUpdate case openFinder @@ -13,6 +14,8 @@ enum DashboardSecondaryAction: String, CaseIterable, Equatable, Hashable, Identi var title: String { switch self { + case .refreshStatus: + return L10n.string("dashboard.action.refresh_status") case .runCheckup: return L10n.string("dashboard.action.run_checkup") case .installUpdate: @@ -32,6 +35,8 @@ enum DashboardSecondaryAction: String, CaseIterable, Equatable, Hashable, Identi var systemImage: String { switch self { + case .refreshStatus: + return "arrow.clockwise" case .runCheckup: return "stethoscope" case .installUpdate: @@ -71,13 +76,81 @@ struct DeviceDashboardHeaderPresentation: Equatable { PresentationRow(label: L10n.string("dashboard.overview.connection_target"), value: profile.connectionTarget), PresentationRow(label: L10n.string("dashboard.overview.addresses"), value: profile.addressSummary.isEmpty ? L10n.string("value.unknown") : profile.addressSummary), PresentationRow(label: L10n.string("dashboard.overview.model"), value: profile.model ?? L10n.string("value.unknown")), - PresentationRow(label: L10n.string("dashboard.overview.generation"), value: profile.deviceGeneration ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("dashboard.overview.generation"), value: Self.generationValue(for: profile)), PresentationRow(label: L10n.string("dashboard.overview.payload"), value: profile.payloadFamily ?? L10n.string("value.unknown")), PresentationRow(label: L10n.string("dashboard.overview.password"), value: summary.passwordState.title), PresentationRow(label: L10n.string("dashboard.overview.last_install"), value: profile.lastDeploy?.summary ?? L10n.string("value.never")) ] } + private static func generationValue(for profile: DeviceProfile) -> String { + if let syapGeneration = generationFromSyAP(profile.syap) { + return syapGeneration + } + if let modelGeneration = generationFromModel(profile.model) { + return modelGeneration + } + if let coarseGeneration = generationFromCoarseValue(profile.deviceGeneration) { + return coarseGeneration + } + return L10n.string("value.unknown") + } + + private static func generationFromSyAP(_ syap: String?) -> String? { + guard let syap = syap?.trimmingCharacters(in: .whitespacesAndNewlines), !syap.isEmpty else { + return nil + } + return [ + "104": "1st generation", + "105": "2nd generation", + "106": "1st generation", + "108": "3rd generation", + "109": "2nd generation", + "113": "3rd generation", + "114": "4th generation", + "116": "4th generation", + "117": "5th generation", + "119": "5th generation", + "120": "6th generation" + ][syap] + } + + private static func generationFromModel(_ model: String?) -> String? { + guard let model else { + return nil + } + let pattern = #"([0-9]+(?:st|nd|rd|th) generation)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let range = NSRange(model.startIndex.. String? { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "gen1", "tc_gen1": + return "1st generation" + case "gen2", "tc_gen2": + return "2nd generation" + case "gen3", "tc_gen3": + return "3rd generation" + case "gen4", "tc_gen4": + return "4th generation" + case "gen5", "tc_gen5": + return "5th generation" + case "gen6", "tc_gen6": + return "6th generation" + default: + return nil + } + } + private static func formattedDate(_ date: Date) -> String { DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .short) } @@ -180,16 +253,28 @@ struct DeviceDashboardOverviewPresentation: Equatable { let hostWarning: HostCompatibilityWarning? let requiresPasswordReplacement: Bool - init(summary: DeviceDashboardSummary, currentCheckupSummary: DoctorSummary? = nil) { + init( + summary: DeviceDashboardSummary, + currentCheckupSummary: DoctorSummary? = nil, + reachabilitySnapshot: DeviceReachabilitySnapshot? = nil, + isReachabilityRunning: Bool = false + ) { let secondaryActions = DashboardActionPolicy.secondaryActions(for: summary) self.header = DeviceDashboardHeaderPresentation(summary: summary) self.primaryAction = summary.primaryAction self.isPrimaryActionEnabled = DashboardActionPolicy.isEnabled(summary.primaryAction, for: summary) + && !(isReachabilityRunning && summary.primaryAction.isMutatingOverviewAction) self.secondaryActions = secondaryActions self.disabledSecondaryActions = Set(DashboardSecondaryAction.allCases.filter { !DashboardActionPolicy.isEnabled($0, for: summary) + || (isReachabilityRunning && $0.isMutatingOverviewAction) }) - self.healthSections = Self.healthSections(for: summary, currentCheckupSummary: currentCheckupSummary) + self.healthSections = Self.healthSections( + for: summary, + currentCheckupSummary: currentCheckupSummary, + reachabilitySnapshot: reachabilitySnapshot, + isReachabilityRunning: isReachabilityRunning + ) self.hostWarning = summary.hostWarning self.requiresPasswordReplacement = DashboardActionPolicy.requiresPasswordReplacement(summary.passwordState) } @@ -200,10 +285,18 @@ struct DeviceDashboardOverviewPresentation: Equatable { private static func healthSections( for summary: DeviceDashboardSummary, - currentCheckupSummary: DoctorSummary? + currentCheckupSummary: DoctorSummary?, + reachabilitySnapshot: DeviceReachabilitySnapshot?, + isReachabilityRunning: Bool ) -> [DashboardHealthSection] { [ - DashboardHealthSection(domain: .connection, rows: [connectionRow(for: summary)]), + DashboardHealthSection(domain: .connection, rows: [ + connectionRow( + for: summary, + reachabilitySnapshot: reachabilitySnapshot, + isReachabilityRunning: isReachabilityRunning + ) + ]), DashboardHealthSection(domain: .runtime, rows: [runtimeRow(for: summary, currentCheckupSummary: currentCheckupSummary)]), DashboardHealthSection(domain: .checkup, rows: [ checkupRow(summary: summary, currentCheckupSummary: currentCheckupSummary) @@ -211,7 +304,11 @@ struct DeviceDashboardOverviewPresentation: Equatable { ] } - private static func connectionRow(for summary: DeviceDashboardSummary) -> DashboardHealthRow { + private static func connectionRow( + for summary: DeviceDashboardSummary, + reachabilitySnapshot: DeviceReachabilitySnapshot?, + isReachabilityRunning: Bool + ) -> DashboardHealthRow { switch summary.displayStatus { case .checking, .installing, .maintaining: return DashboardHealthRow( @@ -225,39 +322,73 @@ struct DeviceDashboardOverviewPresentation: Equatable { break } - switch summary.passwordState { - case .available: + if isReachabilityRunning { return DashboardHealthRow( - id: "connection-password-available", + id: "connection-refreshing", title: DashboardHealthDomain.connection.title, - detail: L10n.string("dashboard.health.connection.password_available"), - status: .good - ) - case .unknown, .missing: - return DashboardHealthRow( - id: "connection-password-missing", - title: DashboardHealthDomain.connection.title, - detail: L10n.string("dashboard.health.connection.password_missing"), - status: .warning, - action: .replacePassword + detail: L10n.string("dashboard.health.connection.refreshing"), + status: .running ) + } + + switch summary.passwordState { case .invalid: - return DashboardHealthRow( - id: "connection-password-invalid", - title: DashboardHealthDomain.connection.title, - detail: L10n.string("dashboard.health.connection.password_invalid"), - status: .failed, - action: .replacePassword - ) + return passwordIssueRow(id: "connection-password-invalid", detailKey: "dashboard.health.connection.password_invalid") case .keychainUnavailable: + return passwordIssueRow(id: "connection-keychain-unavailable", detailKey: "dashboard.health.connection.keychain_unavailable") + case .available, .unknown, .missing: + break + } + + if let reachabilitySnapshot { + return reachabilityRow(from: reachabilitySnapshot) + } + + switch summary.passwordState { + case .available, .unknown, .missing: return DashboardHealthRow( - id: "connection-keychain-unavailable", + id: "connection-not-refreshed", title: DashboardHealthDomain.connection.title, - detail: L10n.string("dashboard.health.connection.keychain_unavailable"), - status: .failed, - action: .replacePassword + detail: L10n.string("dashboard.health.connection.not_refreshed"), + status: .unknown, + action: .refreshStatus ) + case .invalid: + return passwordIssueRow(id: "connection-password-invalid", detailKey: "dashboard.health.connection.password_invalid") + case .keychainUnavailable: + return passwordIssueRow(id: "connection-keychain-unavailable", detailKey: "dashboard.health.connection.keychain_unavailable") + } + } + + private static func passwordIssueRow(id: String, detailKey: String) -> DashboardHealthRow { + DashboardHealthRow( + id: id, + title: DashboardHealthDomain.connection.title, + detail: L10n.string(detailKey), + status: .failed, + action: .replacePassword + ) + } + + private static func reachabilityRow(from snapshot: DeviceReachabilitySnapshot) -> DashboardHealthRow { + let status: DashboardHealthStatus + switch snapshot.payload.status.lowercased() { + case "reachable": + status = .good + case "partial": + status = .warning + case "unreachable": + status = .failed + default: + status = .unknown } + return DashboardHealthRow( + id: "connection-reachability-\(snapshot.payload.status.lowercased())", + title: DashboardHealthDomain.connection.title, + detail: snapshot.payload.summary, + status: status, + action: .refreshStatus + ) } private static func runtimeRow( diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index 935b54f9..810f3b84 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -5,9 +5,6 @@ import Foundation final class DeviceDashboardSession: ObservableObject, Identifiable { let id: DeviceProfile.ID @Published var selectedTab: DeviceDashboardTab = .overview - @Published var replacementPassword = "" - @Published var isReplacingPassword = false - @Published private(set) var passwordError: String? let appStore: AppStore var deployStore: DeployWorkflowStore @@ -74,6 +71,8 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { func performSecondaryAction(_ action: DashboardSecondaryAction, profile: DeviceProfile) { switch action { + case .refreshStatus: + refreshReachability(profile: profile) case .runCheckup: runCheckup(profile: profile) case .installUpdate: @@ -195,31 +194,17 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } } - func saveReplacementPassword(for profile: DeviceProfile) async { - let password = replacementPassword - guard !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - passwordError = L10n.string("password.error.required") - isReplacingPassword = true - return - } - do { - try await appStore.savePassword(password, for: profile) - replacementPassword = "" - passwordError = nil - isReplacingPassword = false - } catch { - passwordError = error.localizedDescription - isReplacingPassword = true - } + func viewCheckupAfterFlashNotice() { + flashStore.dismissManualPowerCycleNotice() + selectedTab = .checkup } func runCheckup(profile: DeviceProfile) { guard let password = appStore.password(for: profile) else { - passwordError = L10n.string("password.error.required") - isReplacingPassword = true + promptForPasswordReplacement(error: L10n.string("password.error.required")) return } - passwordError = nil + profileEditorStore.clearPasswordAttention() selectedTab = .checkup if case .started(let operation) = doctorStore.runDoctor(password: password, profile: profile) { activeCheckupOperation = operation @@ -228,35 +213,36 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { func runInstallPlan(profile: DeviceProfile) { guard let password = appStore.password(for: profile) else { - passwordError = L10n.string("password.error.required") - isReplacingPassword = true + promptForPasswordReplacement(error: L10n.string("password.error.required")) return } - passwordError = nil + profileEditorStore.clearPasswordAttention() selectedTab = .install _ = deployStore.runPlan(password: password, profile: profile) } func runInstall(profile: DeviceProfile) { guard let password = appStore.password(for: profile) else { - passwordError = L10n.string("password.error.required") - isReplacingPassword = true + promptForPasswordReplacement(error: L10n.string("password.error.required")) return } - passwordError = nil + profileEditorStore.clearPasswordAttention() selectedTab = .install if case .started(let operation) = deployStore.runDeploy(password: password, profile: profile) { activeDeployOperation = operation } } + func refreshReachability(profile: DeviceProfile) { + appStore.reachabilityStore.refresh(profile: profile, password: appStore.password(for: profile)) + } + func maintenancePassword(for profile: DeviceProfile) -> String? { guard let password = appStore.password(for: profile) else { - passwordError = L10n.string("password.error.required") - isReplacingPassword = true + promptForPasswordReplacement(error: L10n.string("password.error.required")) return nil } - passwordError = nil + profileEditorStore.clearPasswordAttention() selectedTab = .maintenance return password } @@ -300,10 +286,12 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } private func showPasswordReplacement() { - replacementPassword = "" - passwordError = nil - isReplacingPassword = true - selectedTab = .overview + promptForPasswordReplacement(error: nil) + } + + private func promptForPasswordReplacement(error: String?) { + profileEditorStore.requestPasswordReplacement(error: error) + selectedTab = .settings } func applyProfileSettings(_ settings: DeviceProfileSettings) { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceReachabilityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceReachabilityStore.swift new file mode 100644 index 00000000..8e3e64c5 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceReachabilityStore.swift @@ -0,0 +1,154 @@ +import Combine +import Foundation + +struct DeviceReachabilitySnapshot: Equatable { + let refreshedAt: Date + let payload: ReachabilityPayload +} + +@MainActor +final class DeviceReachabilityStore: ObservableObject { + @Published private(set) var snapshots: [DeviceProfile.ID: DeviceReachabilitySnapshot] = [:] + @Published private(set) var errors: [DeviceProfile.ID: BackendErrorViewModel] = [:] + @Published private(set) var currentStages: [DeviceProfile.ID: OperationStageState] = [:] + + private let coordinator: OperationCoordinator + private let now: () -> Date + private var activeOperations: [DeviceProfile.ID: ActiveOperation] = [:] + private var lastProcessedEventCounts: [DeviceProfile.ID: Int] = [:] + private var observedProfiles: Set = [] + private var cancellablesByProfile: [DeviceProfile.ID: Set] = [:] + + init(coordinator: OperationCoordinator, now: @escaping () -> Date = Date.init) { + self.coordinator = coordinator + self.now = now + } + + func refresh(profile: DeviceProfile, password: String?) { + let laneKey = OperationLaneKey.device(profile.id) + let lane = coordinator.lane(for: laneKey) + observeLane(for: profile.id, lane: lane) + guard !lane.isBusy else { + activeOperations[profile.id] = nil + errors[profile.id] = BackendErrorViewModel( + operation: "reachability", + code: "operation_rejected", + message: L10n.string("operation.error.already_running") + ) + return + } + lane.clear() + errors[profile.id] = nil + currentStages[profile.id] = nil + lastProcessedEventCounts[profile.id] = 0 + switch coordinator.run( + operation: "reachability", + params: OperationParams.reachability(profile: profile, password: password), + context: profile.runtimeContext, + activeDeviceID: profile.id, + laneKey: laneKey + ) { + case .started(let operation): + activeOperations[profile.id] = operation + case .rejected(let message): + activeOperations[profile.id] = nil + errors[profile.id] = BackendErrorViewModel( + operation: "reachability", + code: "operation_rejected", + message: message + ) + } + } + + func snapshot(for profile: DeviceProfile) -> DeviceReachabilitySnapshot? { + snapshots[profile.id] + } + + func error(for profile: DeviceProfile) -> BackendErrorViewModel? { + errors[profile.id] + } + + func currentStage(for profile: DeviceProfile) -> OperationStageState? { + currentStages[profile.id] + } + + func isRunning(profile: DeviceProfile) -> Bool { + activeOperations[profile.id] != nil + || coordinator.activeOperation(for: profile)?.operation == "reachability" + } + + private func observeLane(for profileID: DeviceProfile.ID, lane: OperationLane) { + guard observedProfiles.insert(profileID).inserted else { + return + } + var cancellables: Set = [] + lane.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events, profileID: profileID) + } + } + .store(in: &cancellables) + lane.backend.$isRunning + .sink { [weak self] isRunning in + guard !isRunning else { return } + Task { @MainActor in + self?.finishIfLaneStopped(profileID: profileID) + } + } + .store(in: &cancellables) + cancellablesByProfile[profileID] = cancellables + } + + private func process(_ events: [BackendEvent], profileID: DeviceProfile.ID) { + let previousCount = lastProcessedEventCounts[profileID] ?? 0 + if events.count < previousCount { + lastProcessedEventCounts[profileID] = 0 + } + let start = lastProcessedEventCounts[profileID] ?? 0 + guard events.count > start else { + return + } + for event in events.dropFirst(start) where event.operation == "reachability" { + handle(event, profileID: profileID) + } + lastProcessedEventCounts[profileID] = events.count + } + + private func handle(_ event: BackendEvent, profileID: DeviceProfile.ID) { + if let stage = OperationStageState(event: event) { + currentStages[profileID] = stage + return + } + switch event.type { + case "result": + applyResult(event, profileID: profileID) + case "error": + errors[profileID] = BackendErrorViewModel(event: event) + activeOperations[profileID] = nil + default: + break + } + } + + private func applyResult(_ event: BackendEvent, profileID: DeviceProfile.ID) { + do { + let payload = try event.decodePayload(ReachabilityPayload.self) + snapshots[profileID] = DeviceReachabilitySnapshot(refreshedAt: now(), payload: payload) + errors[profileID] = nil + } catch { + errors[profileID] = BackendErrorViewModel( + operation: "reachability", + code: "contract_error", + message: error.localizedDescription + ) + } + activeOperations[profileID] = nil + } + + private func finishIfLaneStopped(profileID: DeviceProfile.ID) { + if coordinator.activeOperation(for: .device(profileID))?.operation != "reachability" { + activeOperations[profileID] = nil + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift index fad9ec12..79a9ed67 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift @@ -192,6 +192,10 @@ extension FlashManualPowerCycleNotice { var actionTitle: String { L10n.string("action.ok") } + + var viewCheckupActionTitle: String { + L10n.string("dashboard.action.view_checkup") + } } extension FlashPlanMode { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift index 74c6cd06..a18639c7 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift @@ -73,6 +73,8 @@ enum OperationTimelineBuilder { switch operation { case "discover": return L10n.string("timeline.operation.discovery") + case "reachability": + return L10n.string("timeline.operation.reachability") case "configure": return L10n.string("timeline.operation.configure") case "deploy": @@ -107,6 +109,18 @@ enum OperationTimelineBuilder { switch (operation, stage) { case ("discover", "bonjour_discovery"): return L10n.string("timeline.stage.finding_time_capsules") + case ("reachability", "build_candidates"): + return L10n.string("timeline.stage.reachability_candidates") + case ("reachability", "check_dns"): + return L10n.string("timeline.stage.reachability_dns") + case ("reachability", "check_ping"): + return L10n.string("timeline.stage.reachability_ping") + case ("reachability", "check_ssh_port"): + return L10n.string("timeline.stage.reachability_ssh_port") + case ("reachability", "check_ssh_auth"): + return L10n.string("timeline.stage.reachability_ssh_auth") + case ("reachability", "check_smb_port"): + return L10n.string("timeline.stage.reachability_smb_port") case ("configure", "ssh_probe"), ("configure", "ssh_probe_after_acp"): return L10n.string("timeline.stage.checking_ssh") case ("configure", "confirm_enable_ssh"): diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift index d81eaff4..ea8e20eb 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift @@ -18,6 +18,7 @@ final class AppSettingsStoreTests: XCTestCase { let temp = try TemporaryDirectory() let settingsURL = temp.url.appendingPathComponent("settings.json") let saved = AppSettings( + language: .simplifiedChinese, defaultBonjourTimeoutSeconds: 12.5, defaultDeviceSettings: DeviceProfileSettings( nbnsEnabled: false, @@ -45,6 +46,19 @@ final class AppSettingsStoreTests: XCTestCase { XCTAssertEqual(reader.settings, saved) } + func testLegacySettingsWithoutLanguageUseSystemDefault() async throws { + let temp = try TemporaryDirectory() + let settingsURL = temp.url.appendingPathComponent("settings.json") + try #"{"telemetryEnabled":false}"#.write(to: settingsURL, atomically: true, encoding: .utf8) + let store = AppSettingsStore(settingsURL: settingsURL) + + await store.load() + + XCTAssertEqual(store.state, .loaded) + XCTAssertEqual(store.settings.language, .system) + XCTAssertFalse(store.settings.telemetryEnabled) + } + func testCorruptSettingsFailsWithoutReplacingDefaults() async throws { let temp = try TemporaryDirectory() let settingsURL = temp.url.appendingPathComponent("settings.json") @@ -76,9 +90,41 @@ final class AppSettingsStoreTests: XCTestCase { XCTAssertThrowsError(try draft.validatedSettings()) { error in XCTAssertEqual(error as? AppSettingsValidationError, .invalidVersionCheckURL) } + + draft = AppSettingsDraft(settings: .default) + draft.language = .simplifiedChinese + XCTAssertEqual(try draft.validatedSettings().language, .simplifiedChinese) + } + + func testLocalizationLanguageOverrideUsesSelectedBundleAndEnglishFallback() { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } + + XCTAssertEqual(L10n.string("app_settings.title", language: .english), "Settings") + XCTAssertEqual(L10n.string("app_settings.title", language: .simplifiedChinese), "设置") + XCTAssertEqual( + L10n.string("app_settings.subtitle", language: .simplifiedChinese), + "新设备默认值和 App 级别行为。" + ) + + L10n.apply(language: .simplifiedChinese) + XCTAssertEqual(L10n.string("app_settings.title"), "设置") + } + + func testSimplifiedChineseLocalizationCoversEnglishKeysAndFormatTokens() { + let english = L10n.strings(language: .english) + let simplifiedChinese = L10n.strings(language: .simplifiedChinese) + + XCTAssertFalse(english.isEmpty) + XCTAssertEqual(Set(simplifiedChinese.keys), Set(english.keys)) + for key in english.keys { + XCTAssertEqual(formatTokens(in: simplifiedChinese[key] ?? ""), formatTokens(in: english[key] ?? ""), key) + } } func testSavingSettingsAppliesHelperPathAndRunsTelemetrySyncOnlyWhenNeeded() async throws { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } let temp = try TemporaryDirectory() let runner = StoreTestRunner(responses: [ .init(events: [ @@ -97,11 +143,13 @@ final class AppSettingsStoreTests: XCTestCase { ) var settings = AppSettings.default + settings.language = .simplifiedChinese settings.telemetryEnabled = false try await appStore.saveAppSettings(settings) try await waitUntilStoreState { runner.calls.map(\.operation).contains("set-telemetry") } XCTAssertEqual(runner.calls.first?.params["enabled"], .bool(false)) + XCTAssertEqual(L10n.currentLanguage, .simplifiedChinese) var helperSettings = settings helperSettings.helperPathOverride = "/tmp/tcapsule-helper" @@ -119,4 +167,13 @@ final class AppSettingsStoreTests: XCTestCase { "summary": .string(enabled ? "telemetry is enabled." : "telemetry is disabled.") ]) } + + private func formatTokens(in string: String) -> [String] { + let pattern = "%(?:\\d+\\$)?[@df]" + let regex = try! NSRegularExpression(pattern: pattern) + let range = NSRange(string.startIndex.. DeviceProfile { DeviceProfile.make( id: id, - configuredDevice: try testConfiguredDevice(payloadFamily: payloadFamily), + configuredDevice: try testConfiguredDevice( + syap: syap, + model: model, + payloadFamily: payloadFamily, + deviceGeneration: deviceGeneration + ), discoveredDevice: nil, applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) ) } + private func headerValue( + _ label: String, + in presentation: DeviceDashboardOverviewPresentation + ) throws -> String { + try XCTUnwrap(presentation.header.rows.first { $0.label == label }).value + } + private func row( _ domain: DashboardHealthDomain, in presentation: DeviceDashboardOverviewPresentation diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index b8deb918..b792828f 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -93,11 +93,11 @@ final class DashboardStoreTests: XCTestCase { session.performPrimaryAction(.viewCheckup, profile: profile) XCTAssertEqual(session.selectedTab, .checkup) - session.replacementPassword = "draft" + session.profileEditorStore.replacementPassword = "draft" session.performPrimaryAction(.replacePassword, profile: profile) - XCTAssertEqual(session.selectedTab, .overview) - XCTAssertEqual(session.replacementPassword, "") - XCTAssertTrue(session.isReplacingPassword) + XCTAssertEqual(session.selectedTab, .settings) + XCTAssertEqual(session.profileEditorStore.replacementPassword, "draft") + XCTAssertNil(session.profileEditorStore.passwordError) session.performPrimaryAction(.openSMB, profile: profile) XCTAssertEqual(opener.openedURLs.map(\.absoluteString), ["smb://10.0.0.2"]) @@ -132,7 +132,30 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(opener.openedURLs.map(\.absoluteString), ["smb://jameschang@Office%20Capsule._smb._tcp.local"]) } - func testPasswordReplacementSaveUpdatesPasswordStateAndHidesEditor() async throws { + func testRefreshStatusSecondaryActionRunsReachability() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) + + session.performSecondaryAction(.refreshStatus, profile: profile) + try await waitUntilStoreState { fixture.appStore.reachabilityStore.snapshot(for: profile) != nil } + + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["reachability"]) + XCTAssertEqual(fixture.runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(fixture.appStore.reachabilityStore.snapshot(for: profile)?.payload.status, "reachable") + } + + func testProfileEditorPasswordSaveUpdatesPasswordStateAndClearsDraft() async throws { let fixture = try await makeFixture(responses: []) let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), @@ -142,18 +165,17 @@ final class DashboardStoreTests: XCTestCase { ) let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) session.performPrimaryAction(.replacePassword, profile: profile) - session.replacementPassword = "new-password" + session.profileEditorStore.replacementPassword = "new-password" - await session.saveReplacementPassword(for: profile) + await session.profileEditorStore.save(profile: profile) - XCTAssertFalse(session.isReplacingPassword) - XCTAssertEqual(session.replacementPassword, "") - XCTAssertNil(session.passwordError) + XCTAssertEqual(session.profileEditorStore.replacementPassword, "") + XCTAssertNil(session.profileEditorStore.passwordError) XCTAssertEqual(try fixture.passwordStore.password(for: profile.keychainAccount), "new-password") XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .available) } - func testPasswordReplacementSaveFailureKeepsEditorOpen() async throws { + func testProfileEditorPasswordSaveFailureKeepsDraft() async throws { let fixture = try await makeFixture(responses: []) let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), @@ -163,12 +185,12 @@ final class DashboardStoreTests: XCTestCase { ) fixture.passwordStore.saveFailure = .save let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) - session.replacementPassword = "new-password" + session.profileEditorStore.replacementPassword = "new-password" - await session.saveReplacementPassword(for: profile) + await session.profileEditorStore.save(profile: profile) - XCTAssertTrue(session.isReplacingPassword) - XCTAssertEqual(session.passwordError, "In-memory password store save failed.") + XCTAssertEqual(session.profileEditorStore.replacementPassword, "new-password") + XCTAssertEqual(session.profileEditorStore.passwordError, "In-memory password store save failed.") XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) } @@ -190,7 +212,7 @@ final class DashboardStoreTests: XCTestCase { let firstSession = dashboard.session(for: first) firstSession.selectedTab = .maintenance - firstSession.replacementPassword = "draft" + firstSession.profileEditorStore.replacementPassword = "draft" firstSession.deployStore.mountWait = "77" firstSession.maintenanceStore.selectedWorkflow = .fsck @@ -198,7 +220,7 @@ final class DashboardStoreTests: XCTestCase { XCTAssertFalse(firstSession === secondSession) XCTAssertEqual(secondSession.selectedTab, .overview) - XCTAssertEqual(secondSession.replacementPassword, "") + XCTAssertEqual(secondSession.profileEditorStore.replacementPassword, "") XCTAssertEqual(secondSession.deployStore.mountWait, "30") XCTAssertEqual(secondSession.maintenanceStore.selectedWorkflow, .activate) } @@ -539,7 +561,8 @@ final class DashboardStoreTests: XCTestCase { session.runCheckup(profile: profile) - XCTAssertEqual(session.passwordError, "Password is required.") + XCTAssertEqual(session.profileEditorStore.passwordError, "Password is required.") + XCTAssertEqual(session.selectedTab, .settings) try await waitUntilStoreState { fixture.registry.profile(id: profile.id)?.passwordState == .missing } @@ -607,8 +630,8 @@ final class DashboardStoreTests: XCTestCase { error: error, profile: profile )) - XCTAssertEqual(session.selectedTab, .overview) - XCTAssertTrue(session.isReplacingPassword) + XCTAssertEqual(session.selectedTab, .settings) + XCTAssertNil(session.profileEditorStore.passwordError) } func testRecoveryRunCheckupAndInstallActionsStartBackendOperations() async throws { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift index 5afd9bcf..8a4ed9cb 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift @@ -150,6 +150,77 @@ final class DeviceProfileEditorStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls, []) } + func testPasswordOnlySaveUpdatesKeychainAndClearsDraft() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + XCTAssertFalse(store.canSave) + store.replacementPassword = " " + XCTAssertFalse(store.canSave) + XCTAssertEqual(store.state, .clean) + + store.replacementPassword = "new-password" + XCTAssertTrue(store.canSave) + + await store.save(profile: profile) + + XCTAssertEqual(store.state, .saved) + XCTAssertEqual(store.replacementPassword, "") + XCTAssertNil(store.passwordError) + XCTAssertEqual(try fixture.passwordStore.password(for: profile.keychainAccount), "new-password") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .available) + XCTAssertEqual(fixture.runner.calls, []) + } + + func testPasswordSaveFailureKeepsDraftAndDoesNotMarkAvailable() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + fixture.passwordStore.saveFailure = .save + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + store.replacementPassword = "new-password" + + await store.save(profile: profile) + + XCTAssertEqual(store.state, .failed) + XCTAssertEqual(store.replacementPassword, "new-password") + XCTAssertEqual(store.passwordError, "In-memory password store save failed.") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) + } + + func testResetClearsPendingProfileAndPasswordChanges() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.nbnsEnabled.toggle() + store.replacementPassword = "new-password" + XCTAssertTrue(store.canSave) + + store.reset(to: profile) + + XCTAssertEqual(store.state, .clean) + XCTAssertFalse(store.canSave) + XCTAssertEqual(store.replacementPassword, "") + XCTAssertNil(store.passwordError) + XCTAssertEqual(store.draft, DeviceProfileEditorDraft(profile: profile)) + } + func testBlankDisplayNameIsAllowedAndFallsBackThroughTitlePolicy() async throws { let fixture = try await makeFixture(responses: []) let profile = try await fixture.registry.saveConfiguredDevice( @@ -223,6 +294,40 @@ final class DeviceProfileEditorStoreTests: XCTestCase { XCTAssertEqual(fixture.registry.profile(id: profile.id)?.host, "10.0.0.2") } + func testChangedHostUsesReplacementPasswordWhenSavedPasswordIsMissing() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "configure", + ok: true, + payload: testConfigurePayload(host: "root@10.0.0.9", syap: "119", model: "TimeCapsule8,119") + ) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.host = "10.0.0.9" + store.replacementPassword = "new-password" + + await store.save(profile: profile) + + try await waitUntilStoreState { store.state == .saved } + let call = try XCTUnwrap(fixture.runner.calls.first) + XCTAssertEqual(call.operation, "configure") + XCTAssertEqual(call.params["password"], .string("new-password")) + XCTAssertEqual(store.replacementPassword, "") + XCTAssertNil(store.passwordError) + XCTAssertEqual(try fixture.passwordStore.password(for: profile.keychainAccount), "new-password") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .available) + } + func testChangedHostRunsConfigureWithExistingProfileContextAndPreservesProfileData() async throws { let fixture = try await makeFixture(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceReachabilityStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceReachabilityStoreTests.swift new file mode 100644 index 00000000..0ead94e3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceReachabilityStoreTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceReachabilityStoreTests: XCTestCase { + func testRefreshRunsReachabilityOnDeviceLaneAndStoresSnapshot() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "reachability", stage: "check_ssh_port"), + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeviceReachabilityStore(coordinator: coordinator, now: { Date(timeIntervalSince1970: 123) }) + let profile = try makeProfile(host: "10.0.0.2") + + store.refresh(profile: profile, password: "pw") + try await waitUntilStoreState { store.snapshot(for: profile) != nil } + + XCTAssertEqual(runner.calls.map(\.operation), ["reachability"]) + XCTAssertEqual(runner.calls[0].context?.profileID, profile.id) + XCTAssertEqual(runner.calls[0].params["ssh_host"], .string("root@10.0.0.2")) + XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(store.snapshot(for: profile)?.payload.status, "reachable") + XCTAssertEqual(store.snapshot(for: profile)?.refreshedAt, Date(timeIntervalSince1970: 123)) + XCTAssertNil(store.error(for: profile)) + } + + func testRefreshCanRunWithoutPassword() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload( + status: "partial", + summary: "SSH reachable, SMB port closed." + )) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeviceReachabilityStore(coordinator: coordinator) + let profile = try makeProfile(host: "root@10.0.0.2") + + store.refresh(profile: profile, password: nil) + try await waitUntilStoreState { store.snapshot(for: profile) != nil } + + XCTAssertNil(runner.calls[0].params["credentials"]) + XCTAssertEqual(store.snapshot(for: profile)?.payload.status, "partial") + } + + func testErrorEventIsStoredPerProfile() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "reachability", code: "operation_failed", message: "failed") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeviceReachabilityStore(coordinator: coordinator) + let profile = try makeProfile() + + store.refresh(profile: profile, password: "pw") + try await waitUntilStoreState { store.error(for: profile) != nil } + + XCTAssertEqual(store.error(for: profile)?.message, "failed") + XCTAssertNil(store.snapshot(for: profile)) + } + + func testRefreshDoesNotClearBusyDeviceLane() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [], delayNanoseconds: 500_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeviceReachabilityStore(coordinator: coordinator) + let profile = try makeProfile() + + coordinator.run( + operation: "doctor", + params: [:], + context: profile.runtimeContext, + activeDeviceID: profile.id, + laneKey: .device(profile.id) + ) + XCTAssertEqual(coordinator.activeOperation(for: profile)?.operation, "doctor") + + store.refresh(profile: profile, password: "pw") + + XCTAssertEqual(coordinator.activeOperation(for: profile)?.operation, "doctor") + XCTAssertEqual(store.error(for: profile)?.code, "operation_rejected") + XCTAssertEqual(runner.calls.map(\.operation), ["doctor"]) + } + + private func makeProfile(host: String = "10.0.0.2") throws -> DeviceProfile { + DeviceProfile.make( + id: "device-one", + configuredDevice: try testConfiguredDevice(host: host), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift index 74854c3a..6fbc69ff 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift @@ -304,6 +304,11 @@ final class FlashWorkflowStoreTests: XCTestCase { XCTAssertEqual(store.state, .writeValidatedSnapshotStale) XCTAssertEqual(store.manualPowerCycleNotice?.mode, .patch) + XCTAssertEqual( + store.manualPowerCycleNotice?.message, + "Flash write validation completed. Unplug the Time Capsule, wait 10 seconds, then plug it back in. Wait for it to finish booting, then run Checkup. One firmware bank was left untouched." + ) + XCTAssertEqual(store.manualPowerCycleNotice?.viewCheckupActionTitle, "View Checkup") store.dismissManualPowerCycleNotice() diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift index 5d1004c5..c4d89273 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -306,7 +306,8 @@ func testConfigurePayload( configPath: String = "/tmp/profile/.env", syap: String = "119", model: String = "Time Capsule", - payloadFamily: String = "netbsd6_samba4" + payloadFamily: String = "netbsd6_samba4", + deviceGeneration: String = "tc_gen4" ) -> JSONValue { .object([ "schema_version": .number(1), @@ -322,7 +323,7 @@ func testConfigurePayload( "arch": .string("powerpc"), "elf_endianness": .string("big"), "payload_family": .string(payloadFamily), - "device_generation": .string("tc_gen4"), + "device_generation": .string(deviceGeneration), "supported": .bool(true), "syap_candidates": .array([.string(syap)]), "model_candidates": .array([.string(model)]) @@ -341,14 +342,16 @@ func testConfiguredDevice( configPath: String = "/tmp/profile/.env", syap: String = "119", model: String = "Time Capsule", - payloadFamily: String = "netbsd6_samba4" + payloadFamily: String = "netbsd6_samba4", + deviceGeneration: String = "tc_gen4" ) throws -> ConfiguredDeviceState { ConfiguredDeviceState(payload: try testConfigurePayload( host: host, configPath: configPath, syap: syap, model: model, - payloadFamily: payloadFamily + payloadFamily: payloadFamily, + deviceGeneration: deviceGeneration ).decode(ConfigurePayload.self)) } @@ -380,6 +383,37 @@ func testDoctorCheck(status: String, message: String, domain: String) -> JSONVal ]) } +func testReachabilityPayload( + status: String = "reachable", + summary: String = "SSH reachable; SMB port reachable." +) -> JSONValue { + .object([ + "schema_version": .number(1), + "status": .string(status), + "ssh_host": .string("root@10.0.0.2"), + "smb_host": .string("10.0.0.2"), + "checks": .array([ + .object([ + "id": .string("ssh_port"), + "status": .string(status == "unreachable" ? "FAIL" : "PASS"), + "message": .string("SSH port checked."), + "host": .string("10.0.0.2") + ]), + .object([ + "id": .string("smb_port"), + "status": .string(status == "reachable" ? "PASS" : "FAIL"), + "message": .string("SMB port checked."), + "host": .string("10.0.0.2") + ]) + ]), + "counts": .object([ + "PASS": .number(status == "reachable" ? 2 : (status == "partial" ? 1 : 0)), + "FAIL": .number(status == "reachable" ? 0 : (status == "partial" ? 1 : 2)) + ]), + "summary": .string(summary) + ]) +} + func testDeployPlanPayload( payloadFamily: String = "netbsd6_samba4", netbsd4: Bool? = nil, diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index f146a8b8..6288d9fb 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -8,6 +8,7 @@ from timecapsulesmb.identity import InstallIdentity from timecapsulesmb.services.app import jsonable from timecapsulesmb.services.doctor import doctor_status_counts +from timecapsulesmb.services.reachability import ReachabilityResult SCHEMA_VERSION = 1 @@ -116,6 +117,27 @@ def version_check_payload(result: VersionCheckResult) -> dict[str, object]: }) +def reachability_payload(result: ReachabilityResult) -> dict[str, object]: + checks = jsonable(result.checks) + if not isinstance(checks, list): + checks = [] + counts: dict[str, int] = {} + for check in checks: + if not isinstance(check, dict): + continue + status = str(check.get("status") or "").upper() + if status: + counts[status] = counts.get(status, 0) + 1 + return _with_schema({ + "status": result.status, + "ssh_host": result.ssh_host, + "smb_host": result.smb_host, + "checks": checks, + "counts": counts, + "summary": result.summary, + }) + + def configure_payload( *, config_path: str, diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py index a1366dac..81525ebf 100644 --- a/src/timecapsulesmb/app/ops/__init__.py +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -13,6 +13,7 @@ repair_xattrs_operation, uninstall_operation, ) +from timecapsulesmb.app.ops.reachability import reachability_operation from timecapsulesmb.app.ops.readiness import ( capabilities_operation, discover_operation, @@ -35,6 +36,7 @@ "flash": flash_operation, "fsck": fsck_operation, "paths": paths_operation, + "reachability": reachability_operation, "repair-xattrs": repair_xattrs_operation, "set-telemetry": set_telemetry_operation, "telemetry-identity": telemetry_identity_operation, diff --git a/src/timecapsulesmb/app/ops/reachability.py b/src/timecapsulesmb/app/ops/reachability.py new file mode 100644 index 00000000..e3e87d18 --- /dev/null +++ b/src/timecapsulesmb/app/ops/reachability.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from timecapsulesmb.app.contracts import reachability_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.services.app import OperationResult, config_path +from timecapsulesmb.services.credentials import overlay_request_credentials, request_password +from timecapsulesmb.services.reachability import run_reachability +from timecapsulesmb.services.runtime import load_optional_env_config + + +def reachability_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "reachability" + sink.stage(operation, "load_config") + config = load_optional_env_config(env_path=config_path(params)) + config = overlay_request_credentials(config, params) + + result = run_reachability( + config, + params, + password=request_password(params), + stage=lambda stage: sink.stage(operation, stage), + ) + for check in result.checks: + details = {} + if check.host is not None: + details["host"] = check.host + if check.detail is not None: + details["detail"] = check.detail + sink.check(operation, status=check.status, message=check.message, details=details) + return OperationResult(True, reachability_payload(result)) diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py index a7625466..8d874105 100644 --- a/src/timecapsulesmb/app/ops/readiness.py +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -108,6 +108,7 @@ def capabilities_operation(params: dict[str, object], sink: EventSink) -> Operat "flash", "fsck", "paths", + "reachability", "repair-xattrs", "set-telemetry", "telemetry-identity", diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py index c5a49918..1fab608f 100644 --- a/src/timecapsulesmb/app/stage_policy.py +++ b/src/timecapsulesmb/app/stage_policy.py @@ -40,6 +40,13 @@ def to_jsonable(self) -> dict[str, object]: ("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."), + ("reachability", "load_config"): StagePolicy(LOCAL_READ, True, "Read selected device reachability configuration."), + ("reachability", "build_candidates"): StagePolicy(LOCAL_READ, True, "Build selected device host candidates."), + ("reachability", "check_dns"): StagePolicy(LOCAL_READ, True, "Resolve selected device host candidates."), + ("reachability", "check_ping"): StagePolicy(REMOTE_READ, True, "Ping selected device host candidates."), + ("reachability", "check_ssh_port"): StagePolicy(REMOTE_READ, True, "Check selected device SSH port reachability."), + ("reachability", "check_ssh_auth"): StagePolicy(REMOTE_READ, True, "Check selected device SSH authentication."), + ("reachability", "check_smb_port"): StagePolicy(REMOTE_READ, True, "Check selected device SMB port reachability."), ("set-telemetry", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve local app state paths."), ("set-telemetry", "write_bootstrap"): StagePolicy(LOCAL_WRITE, False, "Update local telemetry preference."), ("telemetry-identity", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve local app state paths."), diff --git a/src/timecapsulesmb/services/reachability.py b/src/timecapsulesmb/services/reachability.py new file mode 100644 index 00000000..7df52508 --- /dev/null +++ b/src/timecapsulesmb/services/reachability.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import ipaddress +import shutil +import subprocess +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Callable +from urllib.parse import urlparse + +from timecapsulesmb.core.config import DEFAULTS, AppConfig +from timecapsulesmb.core.net import resolve_host_ips +from timecapsulesmb.transport.errors import TransportError +from timecapsulesmb.transport.local import tcp_connect_error +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, run_ssh, ssh_opts_use_proxy + + +REACHABILITY_OK_TOKEN = "timecapsulesmb-reachability-ok" + + +@dataclass(frozen=True) +class ReachabilityCheck: + id: str + status: str + message: str + host: str | None = None + detail: str | None = None + + +@dataclass(frozen=True) +class ReachabilityResult: + status: str + summary: str + ssh_host: str | None + smb_host: str | None + checks: list[ReachabilityCheck] = field(default_factory=list) + + +def run_reachability( + config: AppConfig, + params: Mapping[str, object], + *, + password: str = "", + stage: Callable[[str], None] | None = None, +) -> ReachabilityResult: + emit_stage(stage, "build_candidates") + ssh_target = ssh_target_from_params(config, params) + ssh_host = endpoint_host(ssh_target) + smb_hosts = smb_hosts_from_params(config, params, ssh_host=ssh_host) + ping_hosts = unique_hosts([ssh_host, *smb_hosts]) + tcp_timeout = non_negative_float(params.get("tcp_timeout"), default=2.0) + ssh_timeout = non_negative_int(params.get("ssh_timeout"), default=8) + + if not ssh_host and not smb_hosts: + check = ReachabilityCheck( + id="candidates", + status="SKIP", + message="No saved host candidates were available.", + ) + return ReachabilityResult( + status="skipped", + summary="No saved host candidates were available.", + ssh_host=ssh_target or None, + smb_host=None, + checks=[check], + ) + + checks: list[ReachabilityCheck] = [] + emit_stage(stage, "check_dns") + checks.append(check_dns(ping_hosts)) + emit_stage(stage, "check_ping") + checks.append(check_ping(ping_hosts, timeout=tcp_timeout)) + emit_stage(stage, "check_ssh_port") + ssh_port = check_ssh_port(ssh_host, config, timeout=tcp_timeout) + checks.append(ssh_port) + emit_stage(stage, "check_ssh_auth") + ssh_auth = check_ssh_auth( + ssh_target, + config, + password=password, + port_check=ssh_port, + timeout=ssh_timeout, + ) + checks.append(ssh_auth) + emit_stage(stage, "check_smb_port") + smb_port = check_smb_port(smb_hosts, timeout=tcp_timeout) + checks.append(smb_port) + + return result_from_checks(ssh_target=ssh_target, smb_hosts=smb_hosts, checks=checks) + + +def ssh_target_from_params(config: AppConfig, params: Mapping[str, object]) -> str: + for key in ("ssh_host", "host"): + value = string_value(params.get(key)) + if value: + return root_ssh_target(value) + return config.get("TC_HOST", "") + + +def smb_hosts_from_params(config: AppConfig, params: Mapping[str, object], *, ssh_host: str) -> list[str]: + candidates: list[str] = [] + add_param_hosts(candidates, params.get("smb_hosts")) + add_param_hosts(candidates, params.get("smb_host")) + add_param_hosts(candidates, params.get("hosts")) + add_param_hosts(candidates, params.get("host")) + if config.has_value("TC_HOST"): + candidates.append(endpoint_host(config.get("TC_HOST"))) + if ssh_host: + candidates.append(ssh_host) + return unique_hosts(candidates) + + +def add_param_hosts(candidates: list[str], value: object) -> None: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + for item in value: + candidates.append(endpoint_host(string_value(item))) + return + candidates.append(endpoint_host(string_value(value))) + + +def endpoint_host(value: str) -> str: + candidate = value.strip() + if not candidate: + return "" + parsed = urlparse(candidate) + if parsed.scheme and parsed.hostname: + return normalize_host(parsed.hostname) + + candidate = candidate.split("/", 1)[0] + if "@" in candidate: + candidate = candidate.rsplit("@", 1)[1] + if candidate.startswith("[") and "]" in candidate: + return normalize_host(candidate[1:candidate.index("]")]) + + if candidate.count(":") == 1: + host_part, port_part = candidate.rsplit(":", 1) + if port_part.isdigit(): + candidate = host_part + return normalize_host(candidate) + + +def normalize_host(value: str) -> str: + candidate = value.strip().strip("[]") + if not candidate: + return "" + try: + ipaddress.ip_address(candidate.split("%", 1)[0]) + return candidate + except ValueError: + return candidate.rstrip(".") + + +def root_ssh_target(value: str) -> str: + candidate = value.strip() + if not candidate or "@" in candidate: + return candidate + return f"root@{candidate}" + + +def check_dns(hosts: Sequence[str]) -> ReachabilityCheck: + if not hosts: + return ReachabilityCheck(id="dns", status="SKIP", message="No hosts were available for DNS resolution.") + + resolved: list[str] = [] + failures: list[str] = [] + for host in hosts: + if is_ip_literal(host): + resolved.append(host) + continue + ips = resolve_host_ips(host) + if ips: + resolved.append(f"{host} -> {', '.join(ips)}") + else: + failures.append(host) + + if resolved: + return ReachabilityCheck( + id="dns", + status="PASS", + message="Host resolution succeeded.", + host=hosts[0], + detail="; ".join(resolved), + ) + return ReachabilityCheck( + id="dns", + status="FAIL", + message="Host resolution failed.", + host=hosts[0], + detail=", ".join(failures) if failures else None, + ) + + +def check_ping(hosts: Sequence[str], *, timeout: float) -> ReachabilityCheck: + if not hosts: + return ReachabilityCheck(id="ping", status="SKIP", message="No hosts were available for ping.") + + failures: list[str] = [] + for host in hosts: + ping = ping_command(host) + if ping is None: + return ReachabilityCheck(id="ping", status="SKIP", message="No ping command is available.") + try: + proc = subprocess.run( + ping, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=max(1.0, timeout + 1.0), + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + failures.append(f"{host}: {type(exc).__name__}") + continue + if proc.returncode == 0: + return ReachabilityCheck( + id="ping", + status="PASS", + message="Host responds to ping.", + host=host, + ) + error = (proc.stderr or b"").decode("utf-8", errors="replace").strip() + failures.append(f"{host}: {error or f'rc={proc.returncode}'}") + + return ReachabilityCheck( + id="ping", + status="FAIL", + message="Host did not respond to ping.", + host=hosts[0], + detail="; ".join(failures), + ) + + +def ping_command(host: str) -> list[str] | None: + command_name = "ping6" if is_ipv6_literal(host) else "ping" + command = shutil.which(command_name) + if command is None and command_name == "ping6": + command = shutil.which("ping") + if command is None: + return None + return [command, "-c", "1", host] + + +def check_ssh_port(host: str, config: AppConfig, *, timeout: float) -> ReachabilityCheck: + if not host: + return ReachabilityCheck(id="ssh_port", status="SKIP", message="No SSH host is configured.") + ssh_opts = config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]) + if ssh_opts_use_proxy(ssh_opts): + return ReachabilityCheck( + id="ssh_port", + status="SKIP", + message="Direct SSH port check skipped because SSH uses a proxy.", + host=host, + ) + error = tcp_connect_error(host, 22, timeout=timeout) + if error is None: + return ReachabilityCheck(id="ssh_port", status="PASS", message="SSH port is reachable.", host=host) + return ReachabilityCheck( + id="ssh_port", + status="FAIL", + message="SSH port is not reachable.", + host=host, + detail=error, + ) + + +def check_ssh_auth( + ssh_target: str, + config: AppConfig, + *, + password: str, + port_check: ReachabilityCheck, + timeout: int, +) -> ReachabilityCheck: + if not ssh_target: + return ReachabilityCheck(id="ssh_auth", status="SKIP", message="No SSH target is configured.") + if not password: + return ReachabilityCheck(id="ssh_auth", status="SKIP", message="SSH authentication skipped because no password is available.") + if port_check.status == "FAIL": + return ReachabilityCheck(id="ssh_auth", status="SKIP", message="SSH authentication skipped because the SSH port is closed.") + + connection = SshConnection( + host=ssh_target, + password=password, + ssh_opts=config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]), + ) + try: + proc = run_ssh( + connection, + f"/bin/sh -c 'printf {REACHABILITY_OK_TOKEN}'", + check=False, + timeout=timeout, + ) + except (TransportError, SshCommandTimeout) as exc: + return ReachabilityCheck( + id="ssh_auth", + status="FAIL", + message="SSH authentication failed.", + host=endpoint_host(ssh_target), + detail=str(exc), + ) + if proc.returncode == 0 and proc.stdout.strip().endswith(REACHABILITY_OK_TOKEN): + return ReachabilityCheck( + id="ssh_auth", + status="PASS", + message="SSH authentication worked.", + host=endpoint_host(ssh_target), + ) + return ReachabilityCheck( + id="ssh_auth", + status="FAIL", + message="SSH authentication failed.", + host=endpoint_host(ssh_target), + detail=proc.stdout.strip() or f"rc={proc.returncode}", + ) + + +def check_smb_port(hosts: Sequence[str], *, timeout: float) -> ReachabilityCheck: + if not hosts: + return ReachabilityCheck(id="smb_port", status="SKIP", message="No SMB hosts are configured.") + + failures: list[str] = [] + for host in hosts: + error = tcp_connect_error(host, 445, timeout=timeout) + if error is None: + return ReachabilityCheck(id="smb_port", status="PASS", message="SMB port is reachable.", host=host) + failures.append(f"{host}: {error}") + + return ReachabilityCheck( + id="smb_port", + status="FAIL", + message="SMB port is not reachable.", + host=hosts[0], + detail="; ".join(failures), + ) + + +def result_from_checks( + *, + ssh_target: str, + smb_hosts: Sequence[str], + checks: Sequence[ReachabilityCheck], +) -> ReachabilityResult: + by_id = {check.id: check for check in checks} + ssh_signal = by_id.get("ssh_auth") and by_id["ssh_auth"].status == "PASS" + if not ssh_signal: + ssh_signal = by_id.get("ssh_port") and by_id["ssh_port"].status == "PASS" + smb_signal = by_id.get("smb_port") and by_id["smb_port"].status == "PASS" + + if ssh_signal and smb_signal: + status = "reachable" + summary = "SSH reachable; SMB port reachable." + elif ssh_signal and not smb_signal: + status = "partial" + summary = "SSH reachable, SMB port closed." + elif smb_signal and not ssh_signal: + status = "partial" + summary = "SMB port reachable, SSH closed." + else: + status = "unreachable" + summary = "Could not reach SSH or SMB." + + smb_host = None + smb_check = by_id.get("smb_port") + if smb_check is not None and smb_check.status == "PASS": + smb_host = smb_check.host + elif smb_hosts: + smb_host = smb_hosts[0] + + return ReachabilityResult( + status=status, + summary=summary, + ssh_host=ssh_target or None, + smb_host=smb_host, + checks=list(checks), + ) + + +def unique_hosts(values: Iterable[str]) -> list[str]: + seen: set[str] = set() + hosts: list[str] = [] + for raw in values: + host = endpoint_host(raw) + if not host: + continue + key = host.lower() + if key in seen: + continue + seen.add(key) + hosts.append(host) + return hosts + + +def is_ip_literal(host: str) -> bool: + try: + ipaddress.ip_address(host.split("%", 1)[0]) + return True + except ValueError: + return False + + +def is_ipv6_literal(host: str) -> bool: + try: + return ipaddress.ip_address(host.split("%", 1)[0]).version == 6 + except ValueError: + return False + + +def string_value(value: object) -> str: + return "" if value is None else str(value).strip() + + +def non_negative_float(value: object, *, default: float) -> float: + if value in (None, ""): + return default + try: + parsed = float(value) + except (TypeError, ValueError): + return default + if parsed < 0: + return default + return parsed + + +def non_negative_int(value: object, *, default: int) -> int: + if value in (None, ""): + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + if parsed < 0: + return default + return parsed + + +def emit_stage(stage: Callable[[str], None] | None, name: str) -> None: + if stage is not None: + stage(name) diff --git a/tests/test_app_api.py b/tests/test_app_api.py index e2e5a4c0..ef75d6fd 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -316,6 +316,7 @@ def test_capabilities_returns_helper_contract_details(self) -> None: self.assertIn("set-telemetry", payload["operations"]) self.assertIn("version-check", payload["operations"]) self.assertIn("flash", payload["operations"]) + self.assertIn("reachability", payload["operations"]) self.assertIn("helper_version", payload) self.assertIn("artifact_manifest_sha256", payload) diff --git a/tests/test_reachability.py b/tests/test_reachability.py new file mode 100644 index 00000000..bcafcd36 --- /dev/null +++ b/tests/test_reachability.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import subprocess +import unittest +from unittest import mock + +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app import service +from timecapsulesmb.core.config import AppConfig, DEFAULTS +from timecapsulesmb.services import reachability + + +class CollectingSink: + def __init__(self) -> None: + self.events: list[dict[str, object]] = [] + self.sink = EventSink(lambda event: self.events.append(event.to_jsonable())) + + def events_of_type(self, event_type: str) -> list[dict[str, object]]: + return [event for event in self.events if event["type"] == event_type] + + +class ReachabilityTests(unittest.TestCase): + def test_reachability_passes_when_ssh_and_smb_work(self) -> None: + config = AppConfig.from_values({ + "TC_HOST": "root@tc.local", + "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"], + }) + + with mock.patch("timecapsulesmb.services.reachability.resolve_host_ips", return_value=("10.0.0.2",)): + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None): + with mock.patch( + "timecapsulesmb.services.reachability.run_ssh", + return_value=subprocess.CompletedProcess(["ssh"], 0, stdout=reachability.REACHABILITY_OK_TOKEN, stderr=""), + ): + result = reachability.run_reachability( + config, + {"smb_hosts": ["tc.local"]}, + password="pw", + ) + + self.assertEqual(result.status, "reachable") + self.assertEqual(result.summary, "SSH reachable; SMB port reachable.") + self.assertEqual({check.id: check.status for check in result.checks}, { + "dns": "PASS", + "ping": "PASS", + "ssh_port": "PASS", + "ssh_auth": "PASS", + "smb_port": "PASS", + }) + + def test_missing_password_skips_auth_but_checks_ports(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None): + with mock.patch("timecapsulesmb.services.reachability.run_ssh") as ssh: + result = reachability.run_reachability(config, {}, password="") + + ssh.assert_not_called() + self.assertEqual(result.status, "reachable") + self.assertEqual(result.summary, "SSH reachable; SMB port reachable.") + self.assertEqual({check.id: check.status for check in result.checks}["ssh_auth"], "SKIP") + + def test_partial_when_ssh_port_works_but_smb_port_is_closed(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + def tcp(host: str, port: int, *, timeout: float) -> str | None: + return None if port == 22 else "connection refused" + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", side_effect=tcp): + result = reachability.run_reachability(config, {}, password="") + + self.assertEqual(result.status, "partial") + self.assertEqual(result.summary, "SSH reachable, SMB port closed.") + + def test_ssh_proxy_skips_direct_port_check_but_auth_can_pass(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": "-J jump"}) + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value="connection refused") as tcp: + with mock.patch( + "timecapsulesmb.services.reachability.run_ssh", + return_value=subprocess.CompletedProcess(["ssh"], 0, stdout=reachability.REACHABILITY_OK_TOKEN, stderr=""), + ) as ssh: + result = reachability.run_reachability(config, {}, password="pw") + + self.assertEqual(tcp.call_count, 1) + ssh.assert_called_once() + self.assertEqual({check.id: check.status for check in result.checks}["ssh_port"], "SKIP") + self.assertEqual({check.id: check.status for check in result.checks}["ssh_auth"], "PASS") + self.assertEqual(result.status, "partial") + + def test_ping_is_secondary_when_tcp_services_fail(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value="connection refused"): + result = reachability.run_reachability(config, {}, password="") + + self.assertEqual(result.status, "unreachable") + self.assertEqual(result.summary, "Could not reach SSH or SMB.") + + def test_all_failed_checks_return_unreachable_without_raising(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@tc.local", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.services.reachability.resolve_host_ips", return_value=()): + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 1, stderr=b"timeout"), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value="connection timed out"): + result = reachability.run_reachability(config, {}, password="") + + self.assertEqual(result.status, "unreachable") + self.assertEqual({check.id: check.status for check in result.checks}["dns"], "FAIL") + self.assertEqual({check.id: check.status for check in result.checks}["ssh_auth"], "SKIP") + + def test_invalid_timeout_params_fall_back_to_defaults(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ) as ping: + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None) as tcp: + result = reachability.run_reachability( + config, + {"tcp_timeout": "not-a-number", "ssh_timeout": "not-a-number"}, + password="", + ) + + self.assertEqual(result.status, "reachable") + self.assertEqual(ping.call_args.kwargs["timeout"], 3.0) + self.assertEqual(tcp.call_args.kwargs["timeout"], 2.0) + + def test_ipv6_candidates_use_ping6_when_available(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@[fd00::2]", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + def which(command: str) -> str | None: + return f"/sbin/{command}" if command == "ping6" else None + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", side_effect=which): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping6"], 0, stderr=b""), + ) as ping: + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None): + reachability.run_reachability(config, {}, password="") + + self.assertEqual(ping.call_args.args[0][0], "/sbin/ping6") + self.assertIn("fd00::2", ping.call_args.args[0]) + + def test_app_operation_emits_stages_checks_and_payload(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.app.ops.reachability.load_optional_env_config", return_value=config): + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None): + with mock.patch( + "timecapsulesmb.services.reachability.run_ssh", + return_value=subprocess.CompletedProcess(["ssh"], 0, stdout=reachability.REACHABILITY_OK_TOKEN, stderr=""), + ): + rc = service.run_api_request( + {"operation": "reachability", "params": {"credentials": {"password": "pw"}}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual( + [event["stage"] for event in collector.events_of_type("stage")], + ["load_config", "build_candidates", "check_dns", "check_ping", "check_ssh_port", "check_ssh_auth", "check_smb_port"], + ) + self.assertEqual(len(collector.events_of_type("check")), 5) + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"]["status"], "reachable") + self.assertEqual(result["payload"]["counts"]["PASS"], 5) + + def test_reachability_does_not_import_zeroconf(self) -> None: + with mock.patch("timecapsulesmb.services.reachability.resolve_host_ips", side_effect=AssertionError("no dns needed")): + result = reachability.run_reachability( + AppConfig.from_values({"TC_HOST": "", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}), + {}, + ) + + self.assertEqual(result.status, "skipped") + + +if __name__ == "__main__": + unittest.main() From d3ebe67672f73622a77b216437b518e68988e9c8 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 05:53:23 -0700 Subject: [PATCH 042/129] Package macOS app --- .../TimeCapsuleSMBApp/App/BundleLayout.swift | 38 +- .../BundleLayoutTests.swift | 33 +- .../HelperLocatorTests.swift | 6 +- macos/TimeCapsuleSMB/tools/package_app.py | 333 ++++++++++++++++-- tests/test_macos_package_app.py | 28 +- 5 files changed, 355 insertions(+), 83 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift index 1d2bb63a..ff8acc8e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift @@ -14,8 +14,7 @@ public enum BundleRuntimeIssueSeverity: String, CaseIterable, Equatable, Sendabl public enum BundleRuntimeIssueCode: String, CaseIterable, Equatable, Sendable { case helperMissing case helperNotExecutable - case pythonRuntimeMissing - case pythonExecutableMissing + case pythonPackagesMissing case distributionRootMissing case artifactManifestMissing case artifactManifestInvalid @@ -62,7 +61,7 @@ public struct BundleLayout: Equatable, Sendable { public let distributionRootURL: URL public let artifactManifestURL: URL public let toolsBinURL: URL - public let pythonRuntimeURL: URL? + public let pythonPackagesURL: URL public let applicationSupportURL: URL public let configURL: URL public let stateDirectoryURL: URL @@ -75,7 +74,7 @@ public struct BundleLayout: Equatable, Sendable { distributionRootURL: URL? = nil, artifactManifestURL: URL? = nil, toolsBinURL: URL? = nil, - pythonRuntimeURL: URL? = nil, + pythonPackagesURL: URL? = nil, applicationSupportURL: URL, configURL: URL? = nil, stateDirectoryURL: URL? = nil @@ -89,7 +88,10 @@ public struct BundleLayout: Equatable, Sendable { self.artifactManifestURL = artifactManifestURL ?? resolvedDistributionRoot.appendingPathComponent("artifact-manifest.json") self.toolsBinURL = toolsBinURL ?? resourceURL.appendingPathComponent("Tools/bin", isDirectory: true) - self.pythonRuntimeURL = pythonRuntimeURL ?? resourceURL.appendingPathComponent("Python", isDirectory: true) + self.pythonPackagesURL = pythonPackagesURL + ?? resourceURL + .appendingPathComponent("Python", isDirectory: true) + .appendingPathComponent("site-packages", isDirectory: true) self.applicationSupportURL = applicationSupportURL self.configURL = configURL ?? applicationSupportURL.appendingPathComponent(".env") self.stateDirectoryURL = stateDirectoryURL ?? applicationSupportURL @@ -140,25 +142,13 @@ public struct BundleLayout: Equatable, Sendable { recovery: "Reinstall TimeCapsuleSMB." )) } - if let pythonRuntimeURL { - if !isDirectory(pythonRuntimeURL, fileManager: fileManager) { - issues.append(BundleRuntimeIssue( - code: .pythonRuntimeMissing, - severity: .error, - message: "The bundled Python runtime is missing.", - recovery: "Reinstall TimeCapsuleSMB." - )) - } else { - let python = pythonRuntimeURL.appendingPathComponent("bin/python") - if !fileManager.isExecutableFile(atPath: python.path) { - issues.append(BundleRuntimeIssue( - code: .pythonExecutableMissing, - severity: .error, - message: "The bundled Python executable is missing or not executable.", - recovery: "Reinstall TimeCapsuleSMB." - )) - } - } + if !isDirectory(pythonPackagesURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .pythonPackagesMissing, + severity: .error, + message: "The bundled Python packages are missing.", + recovery: "Reinstall TimeCapsuleSMB." + )) } if !isDirectory(distributionRootURL, fileManager: fileManager) { issues.append(BundleRuntimeIssue( diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift index 89d3b610..49b3e7d1 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift @@ -10,8 +10,7 @@ final class BundleLayoutTests: XCTestCase { [ .helperMissing, .helperNotExecutable, - .pythonRuntimeMissing, - .pythonExecutableMissing, + .pythonPackagesMissing, .distributionRootMissing, .artifactManifestMissing, .artifactManifestInvalid, @@ -107,20 +106,12 @@ final class BundleLayoutTests: XCTestCase { XCTAssertTrue(issues.contains(where: { $0.code == .distributionArtifactsMissing && $0.severity == .error })) } - func testMissingPythonRuntimeIsBlockingIssue() throws { - let layout = try makeLayout(createPythonRuntime: false) + func testMissingPythonPackagesAreBlockingIssue() throws { + let layout = try makeLayout(createPythonPackages: false) let issues = layout.validationIssues() - XCTAssertTrue(issues.contains(where: { $0.code == .pythonRuntimeMissing && $0.severity == .error })) - } - - func testMissingPythonExecutableIsBlockingIssue() throws { - let layout = try makeLayout(createPythonExecutable: false) - - let issues = layout.validationIssues() - - XCTAssertTrue(issues.contains(where: { $0.code == .pythonExecutableMissing && $0.severity == .error })) + XCTAssertTrue(issues.contains(where: { $0.code == .pythonPackagesMissing && $0.severity == .error })) } func testMissingToolsDirectoryIsWarningIssue() throws { @@ -150,8 +141,7 @@ final class BundleLayoutTests: XCTestCase { createArtifactManifest: Bool = true, artifactManifestContents: String? = nil, createManifestArtifact: Bool = true, - createPythonRuntime: Bool = true, - createPythonExecutable: Bool = true, + createPythonPackages: Bool = true, createTools: Bool = true, applicationSupportURL: URL? = nil ) throws -> BundleLayout { @@ -202,16 +192,11 @@ final class BundleLayoutTests: XCTestCase { ) } } - if createPythonRuntime { - let pythonBin = resources + if createPythonPackages { + let pythonPackages = resources .appendingPathComponent("Python", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) - try FileManager.default.createDirectory(at: pythonBin, withIntermediateDirectories: true) - if createPythonExecutable { - let python = pythonBin.appendingPathComponent("python") - try "#!/bin/sh\nexit 0\n".write(to: python, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: python.path) - } + .appendingPathComponent("site-packages", isDirectory: true) + try FileManager.default.createDirectory(at: pythonPackages, withIntermediateDirectories: true) } if createTools { try FileManager.default.createDirectory( diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift index bc76b639..bb9799d3 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift @@ -177,11 +177,11 @@ final class HelperLocatorTests: XCTestCase { let macOS = contents.appendingPathComponent("MacOS", isDirectory: true) let resources = contents.appendingPathComponent("Resources", isDirectory: true) let helpers = contents.appendingPathComponent("Helpers", isDirectory: true) - let pythonBin = resources.appendingPathComponent("Python/bin", isDirectory: true) + let pythonPackages = resources.appendingPathComponent("Python/site-packages", isDirectory: true) try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: helpers, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: pythonBin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: pythonPackages, withIntermediateDirectories: true) try """ @@ -198,9 +198,7 @@ final class HelperLocatorTests: XCTestCase { """.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 "#!/bin/sh\nexit 0\n".write(to: pythonBin.appendingPathComponent("python"), atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helpers.appendingPathComponent("tcapsule").path) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: pythonBin.appendingPathComponent("python").path) try FileManager.default.createDirectory( at: resources.appendingPathComponent("Distribution/bin", isDirectory: true), withIntermediateDirectories: true diff --git a/macos/TimeCapsuleSMB/tools/package_app.py b/macos/TimeCapsuleSMB/tools/package_app.py index a787117b..873e59d6 100755 --- a/macos/TimeCapsuleSMB/tools/package_app.py +++ b/macos/TimeCapsuleSMB/tools/package_app.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import plistlib @@ -23,7 +24,16 @@ PRODUCT_NAME = "TimeCapsuleSMB" APP_VERSION = CLI_VERSION APP_VERSION_CODE = str(CLI_VERSION_CODE) +APP_ICON_FILE = f"{PRODUCT_NAME}.icns" +APP_ICON_NAME = PRODUCT_NAME +DEFAULT_RUNTIME_PYTHON = "/usr/bin/python3" if Path("/usr/bin/python3").is_file() else sys.executable ARTIFACT_MANIFEST = REPO_ROOT / "src" / "timecapsulesmb" / "assets" / "artifact-manifest.json" +BONJOUR_SERVICE_TYPES = [ + "_airport._tcp", + "_smb._tcp", + "_adisk._tcp", + "_device-info._tcp", +] def run(cmd: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None, input_text: str | None = None) -> subprocess.CompletedProcess[str]: @@ -39,6 +49,16 @@ def run(cmd: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = ) +def run_quiet(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + text=True, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def build_swift(configuration: str) -> Path: run(["swift", "build", "-c", configuration, "--product", PRODUCT_NAME], cwd=PACKAGE_ROOT) executable = PACKAGE_ROOT / ".build" / configuration / PRODUCT_NAME @@ -56,7 +76,7 @@ def copy_resources(configuration: str, resources_dir: Path) -> None: shutil.copytree(resource_bundle, destination) -def write_info_plist(contents_dir: Path) -> None: +def write_info_plist(contents_dir: Path, *, icon_name: str | None = None) -> None: info = { "CFBundleDevelopmentRegion": "en", "CFBundleDisplayName": APP_NAME, @@ -67,13 +87,57 @@ def write_info_plist(contents_dir: Path) -> None: "CFBundleShortVersionString": APP_VERSION, "CFBundleVersion": APP_VERSION_CODE, "LSMinimumSystemVersion": "14.0", + "NSBonjourServices": BONJOUR_SERVICE_TYPES, "NSHighResolutionCapable": True, + "NSLocalNetworkUsageDescription": "TimeCapsuleSMB discovers and connects to Time Capsule devices on your local network.", } + if icon_name: + info["CFBundleIconFile"] = icon_name with (contents_dir / "Info.plist").open("wb") as handle: plistlib.dump(info, handle) (contents_dir / "PkgInfo").write_text("APPL????", encoding="utf-8") +def create_app_icon(source: Path, resources_dir: Path) -> None: + if not source.is_file(): + raise RuntimeError(f"App icon source does not exist: {source}") + + icon_path = resources_dir / APP_ICON_FILE + icon_entries = [ + ("icon_16x16.png", 16), + ("icon_16x16@2x.png", 32), + ("icon_32x32.png", 32), + ("icon_32x32@2x.png", 64), + ("icon_128x128.png", 128), + ("icon_128x128@2x.png", 256), + ("icon_256x256.png", 256), + ("icon_256x256@2x.png", 512), + ("icon_512x512.png", 512), + ("icon_512x512@2x.png", 1024), + ] + + with tempfile.TemporaryDirectory(prefix="timecapsulesmb-iconset-") as tmp: + iconset = Path(tmp) / f"{APP_ICON_NAME}.iconset" + iconset.mkdir() + for filename, size in icon_entries: + run([ + "sips", + "-s", + "format", + "png", + "-z", + str(size), + str(size), + str(source), + "--out", + str(iconset / filename), + ]) + run(["iconutil", "-c", "icns", str(iconset), "-o", str(icon_path)]) + + if not icon_path.is_file(): + raise RuntimeError(f"App icon generation did not produce {icon_path}") + + def write_helper_wrapper(helper_path: Path) -> None: helper_path.write_text( """#!/bin/sh @@ -81,7 +145,8 @@ def write_helper_wrapper(helper_path: Path) -> None: CONTENTS_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" RESOURCES_DIR="$CONTENTS_DIR/Resources" -PYTHON="$RESOURCES_DIR/Python/bin/python" +PYTHON="${TCAPSULE_APP_PYTHON:-/usr/bin/python3}" +PYTHON_PACKAGES="$RESOURCES_DIR/Python/site-packages" if [ -z "${TCAPSULE_STATE_DIR:-}" ]; then export TCAPSULE_STATE_DIR="$HOME/Library/Application Support/TimeCapsuleSMB" @@ -95,6 +160,7 @@ def write_helper_wrapper(helper_path: Path) -> None: mkdir -p "$TCAPSULE_STATE_DIR" export PATH="$RESOURCES_DIR/Tools/bin:${PATH:-/usr/bin:/bin:/usr/sbin:/sbin}" +export PYTHONPATH="$PYTHON_PACKAGES${PYTHONPATH:+:$PYTHONPATH}" export PYTHONNOUSERSITE=1 exec "$PYTHON" -m timecapsulesmb.cli.main "$@" @@ -104,20 +170,43 @@ def write_helper_wrapper(helper_path: Path) -> None: 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 python_major_minor(python: str) -> tuple[int, int]: + code = "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" + completed = subprocess.run( + [python, "-c", code], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + major, minor = completed.stdout.strip().split(".", 1) + return int(major), int(minor) + + +def create_python_packages(python: str, resources_dir: Path) -> None: + python_root = resources_dir / "Python" + if python_root.exists(): + shutil.rmtree(python_root) + python_root.mkdir() + site_packages = python_root / "site-packages" + site_packages.mkdir() + + major, minor = python_major_minor(python) + if (major, minor) < (3, 9): + raise RuntimeError(f"TimeCapsuleSMB.app requires Python 3.9 or newer, got {major}.{minor} from {python}") + + with tempfile.TemporaryDirectory(prefix="timecapsulesmb-package-python-") as tmp: + build_venv = Path(tmp) / "venv" + run([python, "-m", "venv", str(build_venv)]) + build_python = build_venv / "bin" / "python" + run([str(build_python), "-m", "pip", "install", "-U", "pip"]) + generated_build_lib = REPO_ROOT / "build" / "lib" + build_lib_existed = generated_build_lib.exists() + try: + run([str(build_python), "-m", "pip", "install", "--target", str(site_packages), str(REPO_ROOT)]) + finally: + if not build_lib_existed and generated_build_lib.exists(): + shutil.rmtree(generated_build_lib) def copy_distribution(resources_dir: Path) -> None: @@ -168,6 +257,186 @@ def copy_tools(resources_dir: Path, require_tools: bool) -> None: print(f"warning: missing optional bundled tool(s): {', '.join(missing)}", file=sys.stderr) +def macho_dependencies(path: Path) -> list[str] | None: + completed = subprocess.run( + ["otool", "-L", str(path)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + return None + dependencies: list[str] = [] + resolved_path = path.resolve() + for line in completed.stdout.splitlines()[1:]: + stripped = line.strip() + if not stripped: + continue + dependency = stripped.split(" ", 1)[0] + if dependency.startswith("/") and Path(dependency).resolve() == resolved_path: + continue + dependencies.append(dependency) + return dependencies + + +def is_system_macho_dependency(dependency: str) -> bool: + return ( + dependency.startswith("/usr/lib/") + or dependency.startswith("/System/Library/") + or dependency.startswith("@executable_path/") + or dependency.startswith("@loader_path/") + or dependency.startswith("@rpath/") + ) + + +def is_external_macho_dependency(dependency: str) -> bool: + return dependency.startswith("/") and not is_system_macho_dependency(dependency) + + +def bundled_dependency_name(source: Path, used_names: set[str]) -> str: + name = source.name + if name not in used_names: + used_names.add(name) + return name + digest = hashlib.sha256(str(source).encode("utf-8")).hexdigest()[:10] + suffix = "".join(source.suffixes) + stem = source.name[: -len(suffix)] if suffix else source.name + candidate = f"{stem}-{digest}{suffix}" + used_names.add(candidate) + return candidate + + +def loader_path_reference(loader: Path, dependency: Path, frameworks_dir: Path) -> str: + relative_frameworks = os.path.relpath(frameworks_dir, loader.parent) + relative_dependency = Path(relative_frameworks) / dependency.name + return f"@loader_path/{relative_dependency.as_posix()}" + + +def is_library_like_macho(path: Path) -> bool: + return path.suffix in {".dylib", ".so"} or ".framework" in path.parts or path.parent.name in {"lib", "private"} + + +def set_macho_id_if_supported(path: Path) -> None: + if not is_library_like_macho(path): + return + subprocess.run( + ["install_name_tool", "-id", f"@loader_path/{path.name}", str(path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + + +def files_under(roots: list[Path]) -> list[Path]: + candidates: list[Path] = [] + for root in roots: + if not root.exists(): + continue + for path in root.rglob("*"): + if path.is_file(): + candidates.append(path) + return candidates + + +def macho_vendor_roots(app: Path) -> list[Path]: + contents = app / "Contents" + return files_under([ + contents / "Resources" / "Tools" / "bin", + contents / "Frameworks", + ]) + + +def macho_validation_roots(app: Path) -> list[Path]: + contents = app / "Contents" + return files_under([ + contents / "MacOS", + contents / "Resources" / "Tools" / "bin", + contents / "Resources" / "Python" / "site-packages", + contents / "Frameworks", + ]) + + +def vendor_macho_dependencies(app: Path) -> None: + frameworks_dir = app / "Contents" / "Frameworks" + frameworks_dir.mkdir() + source_to_bundle: dict[Path, Path] = {} + used_names: set[str] = set() + queue = macho_vendor_roots(app) + visited: set[Path] = set() + + while queue: + current = queue.pop(0) + current_resolved = current.resolve() + if current_resolved in visited: + continue + visited.add(current_resolved) + + dependencies = macho_dependencies(current) + if dependencies is None: + continue + + for dependency in dependencies: + if not is_external_macho_dependency(dependency): + continue + source = Path(dependency).resolve() + if not source.is_file(): + raise RuntimeError(f"Mach-O dependency does not exist: {dependency} referenced by {current}") + bundled = source_to_bundle.get(source) + if bundled is None: + bundled = frameworks_dir / bundled_dependency_name(source, used_names) + shutil.copy2(source, bundled) + bundled.chmod(bundled.stat().st_mode | 0o200) + source_to_bundle[source] = bundled + queue.append(bundled) + run_quiet([ + "install_name_tool", + "-change", + dependency, + loader_path_reference(current, bundled, frameworks_dir), + str(current), + ]) + + set_macho_id_if_supported(current) + + +def assert_no_external_macho_dependencies(app: Path) -> None: + external: list[str] = [] + for path in macho_validation_roots(app): + dependencies = macho_dependencies(path) + if dependencies is None: + continue + for dependency in dependencies: + if is_external_macho_dependency(dependency): + external.append(f"{path}: {dependency}") + if external: + joined = "\n - ".join(external) + raise RuntimeError(f"App bundle contains non-system Mach-O dependency reference(s):\n - {joined}") + + +def assert_python_dependencies_are_bundled(app: Path) -> None: + env = os.environ.copy() + site_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + env["PYTHONPATH"] = str(site_packages) + env["PYTHONNOUSERSITE"] = "1" + code = ( + "import Crypto, ifaddr, pexpect, timecapsulesmb, zeroconf, zopfli.gzip\n" + "paths = [Crypto.__file__, ifaddr.__file__, pexpect.__file__, timecapsulesmb.__file__, zeroconf.__file__, zopfli.__file__]\n" + "bad = [p for p in paths if not p or '/Contents/Resources/Python/site-packages/' not in p]\n" + "raise SystemExit('\\n'.join(bad) if bad else 0)\n" + ) + completed = subprocess.run( + ["/usr/bin/python3", "-c", code], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError(f"Bundled Python dependencies are not importable from the app package:\n{completed.stderr}") + + def parse_helper_events(stdout: str) -> list[dict[str, object]]: events: list[dict[str, object]] = [] for line in stdout.splitlines(): @@ -203,24 +472,37 @@ def smoke_request(helper: Path, operation: str, state_dir: Path) -> None: raise RuntimeError(f"{operation} smoke test failed:\n{completed.stdout}\n{completed.stderr}") -def assert_bundle_layout(app: Path) -> None: +def assert_bundle_layout(app: Path, *, icon_name: str | None = None) -> None: helper = app / "Contents" / "Helpers" / "tcapsule" - python = app / "Contents" / "Resources" / "Python" / "bin" / "python" + info_plist = app / "Contents" / "Info.plist" distribution = app / "Contents" / "Resources" / "Distribution" artifact_manifest = distribution / "artifact-manifest.json" tools_bin = app / "Contents" / "Resources" / "Tools" / "bin" - required_executables = [helper, python] + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + required_executables = [helper] missing_executables = [path for path in required_executables if not path.is_file() or not os.access(path, os.X_OK)] if missing_executables: joined = "\n - ".join(str(path) for path in missing_executables) raise RuntimeError(f"App bundle is missing required executable(s):\n - {joined}") + if not python_packages.is_dir(): + raise RuntimeError(f"App bundle is missing bundled Python packages: {python_packages}") if not (distribution / "bin").is_dir(): raise RuntimeError(f"App bundle is missing bundled payload directory: {distribution / 'bin'}") if not artifact_manifest.is_file(): raise RuntimeError(f"App bundle is missing bundled artifact manifest: {artifact_manifest}") if not tools_bin.is_dir(): raise RuntimeError(f"App bundle is missing bundled tools directory: {tools_bin}") + if icon_name: + icon_file = app / "Contents" / "Resources" / f"{icon_name}.icns" + if not icon_file.is_file(): + raise RuntimeError(f"App bundle is missing app icon: {icon_file}") + with info_plist.open("rb") as handle: + info = plistlib.load(handle) + if info.get("CFBundleIconFile") != icon_name: + raise RuntimeError(f"Info.plist does not reference app icon {icon_name}") assert_distribution_artifacts(distribution) + assert_python_dependencies_are_bundled(app) + assert_no_external_macho_dependencies(app) def smoke_test(app: Path) -> None: @@ -246,14 +528,18 @@ def package_app(args: argparse.Namespace) -> Path: helpers.mkdir() resources.mkdir() - write_info_plist(contents) + icon_name = APP_ICON_NAME if args.icon else None + write_info_plist(contents, icon_name=icon_name) shutil.copy2(executable, macos / PRODUCT_NAME) copy_resources(args.configuration, resources) + if args.icon: + create_app_icon(args.icon.resolve(), resources) write_helper_wrapper(helpers / "tcapsule") - create_python_runtime(args.python, resources) + create_python_packages(args.python, resources) copy_distribution(resources) copy_tools(resources, args.require_tools) - assert_bundle_layout(app) + vendor_macho_dependencies(app) + assert_bundle_layout(app, icon_name=icon_name) if not args.skip_smoke: smoke_test(app) @@ -264,7 +550,8 @@ 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("--icon", type=Path, help="Source image to convert into the app bundle .icns icon.") + parser.add_argument("--python", default=DEFAULT_RUNTIME_PYTHON, help="Python interpreter used to build app-bundled packages; defaults to macOS /usr/bin/python3.") 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) diff --git a/tests/test_macos_package_app.py b/tests/test_macos_package_app.py index 6f3913c6..811436bd 100644 --- a/tests/test_macos_package_app.py +++ b/tests/test_macos_package_app.py @@ -76,18 +76,17 @@ def test_assert_bundle_layout_checks_helper_python_tools_and_artifacts( package_app = load_package_app_module() app = tmp_path / "TimeCapsuleSMB.app" helper = app / "Contents" / "Helpers" / "tcapsule" - python = app / "Contents" / "Resources" / "Python" / "bin" / "python" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" tools = app / "Contents" / "Resources" / "Tools" / "bin" distribution = app / "Contents" / "Resources" / "Distribution" - for directory in (helper.parent, python.parent, tools, distribution / "bin" / "payloads"): + for directory in (helper.parent, python_packages, tools, distribution / "bin" / "payloads"): directory.mkdir(parents=True) helper.write_text("#!/bin/sh\n", encoding="utf-8") - python.write_text("#!/bin/sh\n", encoding="utf-8") helper.chmod(0o755) - python.chmod(0o755) (distribution / "artifact-manifest.json").write_text('{"artifacts":{}}', encoding="utf-8") monkeypatch.setattr(package_app, "artifact_paths", lambda: ["bin/payloads/one", "bin/payloads/two"]) + monkeypatch.setattr(package_app, "assert_python_dependencies_are_bundled", lambda app: None) (distribution / "bin" / "payloads" / "one").write_text("one", encoding="utf-8") with pytest.raises(RuntimeError, match="missing payload artifact"): @@ -102,15 +101,28 @@ def test_assert_bundle_layout_requires_artifact_manifest(tmp_path: Path) -> None package_app = load_package_app_module() app = tmp_path / "TimeCapsuleSMB.app" helper = app / "Contents" / "Helpers" / "tcapsule" - python = app / "Contents" / "Resources" / "Python" / "bin" / "python" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" tools = app / "Contents" / "Resources" / "Tools" / "bin" distribution = app / "Contents" / "Resources" / "Distribution" - for directory in (helper.parent, python.parent, tools, distribution / "bin"): + for directory in (helper.parent, python_packages, tools, distribution / "bin"): directory.mkdir(parents=True) helper.write_text("#!/bin/sh\n", encoding="utf-8") - python.write_text("#!/bin/sh\n", encoding="utf-8") helper.chmod(0o755) - python.chmod(0o755) with pytest.raises(RuntimeError, match="missing bundled artifact manifest"): package_app.assert_bundle_layout(app) + + +def test_assert_bundle_layout_requires_python_packages(tmp_path: Path) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, tools, distribution / "bin"): + directory.mkdir(parents=True) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + + with pytest.raises(RuntimeError, match="missing bundled Python packages"): + package_app.assert_bundle_layout(app) From b4fb7b8249f97be62add18623b70d88ca8702ff2 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 17:44:52 -0700 Subject: [PATCH 043/129] Add icon --- macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg | Bin 0 -> 515007 bytes macos/TimeCapsuleSMB/tools/package_app.py | 8 +++++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg diff --git a/macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg b/macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg new file mode 100644 index 0000000000000000000000000000000000000000..43368e80c4b80c3abce806ffcc4b4bf7c59f3953 GIT binary patch literal 515007 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�pGCDV|jHd1d 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ν6}fn`p_QT@NwoRl${mldR++8)Lksf)i3XCD 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

QlDn4y-#z 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

`GRQS9&g#R&~6yH?{>okmO&h1qf_gDut- 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 z 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 literal 0 HcmV?d00001 diff --git a/macos/TimeCapsuleSMB/tools/package_app.py b/macos/TimeCapsuleSMB/tools/package_app.py index 873e59d6..dcc056eb 100755 --- a/macos/TimeCapsuleSMB/tools/package_app.py +++ b/macos/TimeCapsuleSMB/tools/package_app.py @@ -26,6 +26,7 @@ APP_VERSION_CODE = str(CLI_VERSION_CODE) APP_ICON_FILE = f"{PRODUCT_NAME}.icns" APP_ICON_NAME = PRODUCT_NAME +DEFAULT_ICON_SOURCE = PACKAGE_ROOT / "Assets" / "AppIcon" / "tcs.jpg" DEFAULT_RUNTIME_PYTHON = "/usr/bin/python3" if Path("/usr/bin/python3").is_file() else sys.executable ARTIFACT_MANIFEST = REPO_ROOT / "src" / "timecapsulesmb" / "assets" / "artifact-manifest.json" BONJOUR_SERVICE_TYPES = [ @@ -550,7 +551,12 @@ 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("--icon", type=Path, help="Source image to convert into the app bundle .icns icon.") + parser.add_argument( + "--icon", + type=Path, + default=DEFAULT_ICON_SOURCE, + help="Source image to convert into the app bundle .icns icon.", + ) parser.add_argument("--python", default=DEFAULT_RUNTIME_PYTHON, help="Python interpreter used to build app-bundled packages; defaults to macOS /usr/bin/python3.") 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.") From fb457ae3234ce294c63b987d0b4e0d0dcb47fec6 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 22:11:36 -0700 Subject: [PATCH 044/129] Re-sign packaged macOS runtime tools after dependency rewriting --- .../App/AppResourceBundle.swift | 83 ++ .../TimeCapsuleSMBApp/App/Localization.swift | 4 +- .../TimeCapsuleSMBExecutable/main.swift | 10 + .../BundleLayoutTests.swift | 22 + macos/TimeCapsuleSMB/tools/package_app.py | 773 ++++++++++++++++-- tests/test_macos_package_app.py | 482 +++++++++++ 6 files changed, 1324 insertions(+), 50 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppResourceBundle.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppResourceBundle.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppResourceBundle.swift new file mode 100644 index 00000000..aac08e59 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppResourceBundle.swift @@ -0,0 +1,83 @@ +import Foundation + +enum AppResourceBundleLocator { + static let bundleDirectoryName = "TimeCapsuleSMBMac_TimeCapsuleSMBApp.bundle" + + static func bundleURL( + appBundleURL: URL = Bundle.main.bundleURL, + resourceURL: URL? = Bundle.main.resourceURL, + fileManager: FileManager = .default + ) -> URL? { + for candidate in candidateURLs(appBundleURL: appBundleURL, resourceURL: resourceURL) { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue { + return candidate + } + } + return nil + } + + static func candidateURLs(appBundleURL: URL, resourceURL: URL?) -> [URL] { + var candidates: [URL] = [] + if let resourceURL { + candidates.append(resourceURL.appendingPathComponent(bundleDirectoryName, isDirectory: true)) + } + candidates.append(appBundleURL.appendingPathComponent("Contents/Resources", isDirectory: true) + .appendingPathComponent(bundleDirectoryName, isDirectory: true)) + candidates.append(appBundleURL.appendingPathComponent(bundleDirectoryName, isDirectory: true)) + candidates.append(appBundleURL.deletingLastPathComponent() + .appendingPathComponent(bundleDirectoryName, isDirectory: true)) + + var seen: Set = [] + return candidates.filter { url in + let key = url.standardizedFileURL.path + if seen.contains(key) { + return false + } + seen.insert(key) + return true + } + } +} + +enum AppResourceBundle { + static var bundle: Bundle { + resolvedBundle + } + + static var bundleURL: URL? { + resolvedBundle.bundleURL + } + + private static let resolvedBundle: Bundle = { + if let url = AppResourceBundleLocator.bundleURL(), + let bundle = Bundle(url: url) { + return bundle + } + #if DEBUG + return Bundle.module + #else + return Bundle.main + #endif + }() +} + +public enum AppLaunchResourceValidation { + public static func validate() -> String? { + guard let bundleURL = AppResourceBundle.bundleURL else { + return "TimeCapsuleSMB resource bundle could not be located." + } + + let localizable = bundleURL + .appendingPathComponent("en.lproj", isDirectory: true) + .appendingPathComponent("Localizable.strings") + guard FileManager.default.isReadableFile(atPath: localizable.path) else { + return "TimeCapsuleSMB resource bundle is missing en.lproj/Localizable.strings." + } + + guard L10n.string("screen.readiness") == "Readiness" else { + return "TimeCapsuleSMB localized strings did not load from the resource bundle." + } + return nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift index 6f4d0731..17f11a7a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift @@ -26,7 +26,7 @@ enum L10n { } static func string(_ key: String, language: AppLanguage) -> String { - let fallback = NSLocalizedString(key, bundle: .module, comment: "") + let fallback = AppResourceBundle.bundle.localizedString(forKey: key, value: key, table: nil) guard let bundle = bundle(for: language) else { return fallback } @@ -49,7 +49,7 @@ enum L10n { return nil } for candidate in [identifier, identifier.lowercased()] { - if let path = Bundle.module.path(forResource: candidate, ofType: "lproj"), + if let path = AppResourceBundle.bundle.path(forResource: candidate, ofType: "lproj"), let bundle = Bundle(path: path) { return bundle } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift index 36c20107..51ead328 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift @@ -1,4 +1,5 @@ import AppKit +import Darwin import SwiftUI import TimeCapsuleSMBApp @@ -7,6 +8,15 @@ struct TimeCapsuleSMBExecutable: App { @NSApplicationDelegateAdaptor(AppCloseGuardApplicationDelegate.self) private var appCloseGuardDelegate init() { + if CommandLine.arguments.contains("--validate-resources") { + if let error = AppLaunchResourceValidation.validate() { + fputs("\(error)\n", stderr) + exit(70) + } + print("ok") + exit(0) + } + NSApplication.shared.setActivationPolicy(.regular) DispatchQueue.main.async { NSApplication.shared.activate(ignoringOtherApps: true) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift index 49b3e7d1..c96ee301 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift @@ -2,6 +2,28 @@ import XCTest @testable import TimeCapsuleSMBApp final class BundleLayoutTests: XCTestCase { + func testResourceBundleLocatorPrefersPackagedResourceDirectory() throws { + let temp = try TemporaryDirectory() + let app = temp.url.appendingPathComponent("TimeCapsuleSMB.app", isDirectory: true) + let packaged = app + .appendingPathComponent("Contents/Resources", isDirectory: true) + .appendingPathComponent(AppResourceBundleLocator.bundleDirectoryName, isDirectory: true) + let appRoot = app.appendingPathComponent(AppResourceBundleLocator.bundleDirectoryName, isDirectory: true) + try FileManager.default.createDirectory(at: packaged, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: appRoot, withIntermediateDirectories: true) + + let resolved = AppResourceBundleLocator.bundleURL( + appBundleURL: app, + resourceURL: app.appendingPathComponent("Contents/Resources", isDirectory: true) + ) + + XCTAssertEqual(resolved?.standardizedFileURL, packaged.standardizedFileURL) + } + + func testLaunchResourceValidationLoadsLocalizedStrings() { + XCTAssertNil(AppLaunchResourceValidation.validate()) + } + func testStateInventoriesAreExplicit() { XCTAssertEqual(BundleRuntimeMode.allCases, [.explicit, .productionBundle, .developmentCheckout]) XCTAssertEqual(BundleRuntimeIssueSeverity.allCases, [.warning, .error]) diff --git a/macos/TimeCapsuleSMB/tools/package_app.py b/macos/TimeCapsuleSMB/tools/package_app.py index dcc056eb..0c11725a 100755 --- a/macos/TimeCapsuleSMB/tools/package_app.py +++ b/macos/TimeCapsuleSMB/tools/package_app.py @@ -5,11 +5,13 @@ import hashlib import json import os +import platform import plistlib import shutil import subprocess import sys import tempfile +import urllib.request from pathlib import Path @@ -27,8 +29,18 @@ APP_ICON_FILE = f"{PRODUCT_NAME}.icns" APP_ICON_NAME = PRODUCT_NAME DEFAULT_ICON_SOURCE = PACKAGE_ROOT / "Assets" / "AppIcon" / "tcs.jpg" -DEFAULT_RUNTIME_PYTHON = "/usr/bin/python3" if Path("/usr/bin/python3").is_file() else sys.executable ARTIFACT_MANIFEST = REPO_ROOT / "src" / "timecapsulesmb" / "assets" / "artifact-manifest.json" +RESOURCE_BUNDLE_NAME = "TimeCapsuleSMBMac_TimeCapsuleSMBApp.bundle" +PYTHON_RUNTIME_VERSION = "3.13.13" +PYTHON_RUNTIME_URL = f"https://www.python.org/ftp/python/{PYTHON_RUNTIME_VERSION}/python-{PYTHON_RUNTIME_VERSION}-macos11.pkg" +PYTHON_FRAMEWORK_NAME = "Python.framework" +APP_BUNDLED_PYTHON_REQUIREMENTS = ("certifi>=2024.8.30",) +DEFAULT_ARCHITECTURES = ("arm64", "x86_64") +SWIFT_TRIPLES = { + "arm64": "arm64-apple-macosx14.0", + "x86_64": "x86_64-apple-macosx14.0", +} +REQUIRED_HOST_TOOLS = ("sshpass", "smbclient") BONJOUR_SERVICE_TYPES = [ "_airport._tcp", "_smb._tcp", @@ -60,16 +72,70 @@ def run_quiet(cmd: list[str]) -> subprocess.CompletedProcess[str]: ) -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 +def native_architecture() -> str: + machine = platform.machine().lower() + if machine in {"arm64", "arm64e"}: + return "arm64" + if machine in {"x86_64", "amd64"}: + return "x86_64" + raise RuntimeError(f"Unsupported macOS build architecture: {machine}") + + +def resolve_architectures(values: list[str] | None) -> tuple[str, ...]: + requested = values or ["universal"] + architectures: list[str] = [] + for value in requested: + if value == "universal": + candidates = list(DEFAULT_ARCHITECTURES) + elif value == "native": + candidates = [native_architecture()] + else: + candidates = [value] + for candidate in candidates: + if candidate not in SWIFT_TRIPLES: + raise RuntimeError(f"Unsupported architecture: {candidate}") + if candidate not in architectures: + architectures.append(candidate) + return tuple(architectures) + + +def swift_build_dir(configuration: str, architecture: str) -> Path: + return PACKAGE_ROOT / ".build" / f"{architecture}-apple-macosx" / configuration + + +def build_swift(configuration: str, architectures: tuple[str, ...]) -> tuple[Path, Path]: + executables: list[Path] = [] + build_dirs: list[Path] = [] + for architecture in architectures: + run([ + "swift", + "build", + "-c", + configuration, + "--triple", + SWIFT_TRIPLES[architecture], + "--product", + PRODUCT_NAME, + ], cwd=PACKAGE_ROOT) + build_dir = swift_build_dir(configuration, architecture) + executable = build_dir / PRODUCT_NAME + if not executable.is_file(): + raise RuntimeError(f"Swift build did not produce {executable}") + executables.append(executable) + build_dirs.append(build_dir) + + if len(executables) == 1: + return executables[0], build_dirs[0] + + universal_dir = PACKAGE_ROOT / ".build" / "package-app" / configuration + universal_dir.mkdir(parents=True, exist_ok=True) + universal_executable = universal_dir / PRODUCT_NAME + run(["lipo", "-create", *[str(path) for path in executables], "-output", str(universal_executable)]) + universal_executable.chmod(0o755) + return universal_executable, build_dirs[0] + + +def copy_resources(build_dir: Path, resources_dir: Path) -> None: for resource_bundle in build_dir.glob("*.bundle"): destination = resources_dir / resource_bundle.name if destination.exists(): @@ -146,8 +212,15 @@ def write_helper_wrapper(helper_path: Path) -> None: CONTENTS_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" RESOURCES_DIR="$CONTENTS_DIR/Resources" -PYTHON="${TCAPSULE_APP_PYTHON:-/usr/bin/python3}" +PYTHON_HOME="$RESOURCES_DIR/Python/Runtime/Python.framework/Versions/Current" +if [ -z "${TCAPSULE_APP_PYTHON:-}" ]; then + PYTHON="$PYTHON_HOME/bin/python3" + export PYTHONHOME="$PYTHON_HOME" +else + PYTHON="$TCAPSULE_APP_PYTHON" +fi PYTHON_PACKAGES="$RESOURCES_DIR/Python/site-packages" +CA_CERT_FILE="$PYTHON_PACKAGES/certifi/cacert.pem" if [ -z "${TCAPSULE_STATE_DIR:-}" ]; then export TCAPSULE_STATE_DIR="$HOME/Library/Application Support/TimeCapsuleSMB" @@ -163,6 +236,11 @@ def write_helper_wrapper(helper_path: Path) -> None: export PATH="$RESOURCES_DIR/Tools/bin:${PATH:-/usr/bin:/bin:/usr/sbin:/sbin}" export PYTHONPATH="$PYTHON_PACKAGES${PYTHONPATH:+:$PYTHONPATH}" export PYTHONNOUSERSITE=1 +export PYTHONDONTWRITEBYTECODE=1 +if [ -f "$CA_CERT_FILE" ]; then + export SSL_CERT_FILE="${SSL_CERT_FILE:-$CA_CERT_FILE}" + export REQUESTS_CA_BUNDLE="${REQUESTS_CA_BUNDLE:-$CA_CERT_FILE}" +fi exec "$PYTHON" -m timecapsulesmb.cli.main "$@" """, @@ -184,12 +262,176 @@ def python_major_minor(python: str) -> tuple[int, int]: return int(major), int(minor) +def package_cache_dir(name: str) -> Path: + path = PACKAGE_ROOT / ".build" / "package-app" / name + path.mkdir(parents=True, exist_ok=True) + return path + + +def download_file(url: str, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + with urllib.request.urlopen(url, timeout=60) as response: + with destination.open("wb") as handle: + shutil.copyfileobj(response, handle) + + +def python_runtime_pkg(args: argparse.Namespace) -> Path: + if args.python_runtime_pkg: + return args.python_runtime_pkg.resolve() + cache_dir = package_cache_dir("python-runtime") + filename = Path(args.python_runtime_url).name or f"python-{PYTHON_RUNTIME_VERSION}-macos11.pkg" + destination = cache_dir / filename + if not destination.is_file(): + print(f"Downloading bundled Python runtime: {args.python_runtime_url}", file=sys.stderr) + download_file(args.python_runtime_url, destination) + return destination + + +def extract_python_framework(pkg_path: Path, destination: Path) -> Path: + if destination.exists(): + shutil.rmtree(destination) + with tempfile.TemporaryDirectory(prefix="timecapsulesmb-python-runtime-") as tmp: + expanded = Path(tmp) / "expanded" + run(["pkgutil", "--expand-full", str(pkg_path), str(expanded)]) + payload = expanded / "Python_Framework.pkg" / "Payload" + if (payload / "Versions" / "Current" / "Python").is_file(): + shutil.copytree(payload, destination, symlinks=True) + return destination + frameworks = [path for path in expanded.rglob(PYTHON_FRAMEWORK_NAME) if (path / "Versions" / "Current" / "Python").is_file()] + if not frameworks: + raise RuntimeError(f"Python runtime package does not contain {PYTHON_FRAMEWORK_NAME}: {pkg_path}") + shutil.copytree(frameworks[0], destination, symlinks=True) + return destination + + +def copy_python_runtime(args: argparse.Namespace, resources_dir: Path, architectures: tuple[str, ...]) -> Path: + runtime_dir = resources_dir / "Python" / "Runtime" + if runtime_dir.exists(): + shutil.rmtree(runtime_dir) + runtime_dir.mkdir(parents=True) + framework = runtime_dir / PYTHON_FRAMEWORK_NAME + + if args.python_runtime_framework: + shutil.copytree(args.python_runtime_framework.resolve(), framework, symlinks=True) + else: + extract_python_framework(python_runtime_pkg(args), framework) + + prune_python_runtime(framework) + rewrite_python_framework_install_names(framework) + python_executable = bundled_python_executable_from_resources(resources_dir) + if not python_executable.is_file(): + raise RuntimeError(f"Bundled Python executable is missing: {python_executable}") + assert_macho_has_architectures(python_executable, architectures, "Bundled Python executable") + assert_macho_has_architectures(bundled_python_dylib_from_resources(resources_dir), architectures, "Bundled Python framework") + return python_executable + + +def bundled_python_home(app: Path) -> Path: + return bundled_python_framework(app) / "Versions" / "Current" + + +def bundled_python_framework(app: Path) -> Path: + return app / "Contents" / "Resources" / "Python" / "Runtime" / PYTHON_FRAMEWORK_NAME + + +def bundled_python_executable(app: Path) -> Path: + return bundled_python_home(app) / "bin" / "python3" + + +def bundled_python_dylib(app: Path) -> Path: + return bundled_python_home(app) / "Python" + + +def bundled_python_executable_from_resources(resources_dir: Path) -> Path: + return resources_dir / "Python" / "Runtime" / PYTHON_FRAMEWORK_NAME / "Versions" / "Current" / "bin" / "python3" + + +def bundled_python_dylib_from_resources(resources_dir: Path) -> Path: + return resources_dir / "Python" / "Runtime" / PYTHON_FRAMEWORK_NAME / "Versions" / "Current" / "Python" + + +def framework_version_dir(framework: Path) -> Path: + current = framework / "Versions" / "Current" + if (current / "Python").exists(): + return current.resolve() + versions = [path for path in (framework / "Versions").iterdir() if path.is_dir() and (path / "Python").is_file()] + if not versions: + raise RuntimeError(f"Bundled Python framework has no version directory: {framework}") + return versions[0] + + +def loader_relative_reference(loader: Path, dependency: Path) -> str: + return f"@loader_path/{os.path.relpath(dependency, loader.parent)}" + + +def rewrite_python_framework_install_names(framework: Path) -> None: + version_dir = framework_version_dir(framework) + original_prefix = f"/Library/Frameworks/{PYTHON_FRAMEWORK_NAME}/Versions/{version_dir.name}/" + changed: set[Path] = set() + for path in macho_files_under([framework]): + if not macho_architectures(path): + continue + dependencies = macho_dependencies(path) + if dependencies is None: + continue + for dependency in dependencies: + if not dependency.startswith(original_prefix): + continue + bundled_dependency = version_dir / dependency.removeprefix(original_prefix) + if not bundled_dependency.exists(): + continue + run_quiet([ + "install_name_tool", + "-change", + dependency, + loader_relative_reference(path, bundled_dependency), + str(path), + ]) + changed.add(path) + if path.resolve() == (version_dir / "Python").resolve(): + run_quiet([ + "install_name_tool", + "-id", + f"@rpath/{PYTHON_FRAMEWORK_NAME}/Versions/{version_dir.name}/Python", + str(path), + ]) + changed.add(path) + elif is_library_like_macho(path) and path.suffix not in {".a", ".so"}: + run_quiet(["install_name_tool", "-id", f"@loader_path/{path.name}", str(path)]) + changed.add(path) + for path in changed: + ad_hoc_codesign(path) + + +def prune_python_runtime(framework: Path) -> None: + version_dir = framework_version_dir(framework) + for path in (version_dir / "bin").glob("*-intel64"): + path.unlink() + for relative_path in ( + "Frameworks/Tcl.framework", + "Frameworks/Tk.framework", + "lib/tcl8", + "lib/tcl8.6", + "lib/tk8.6", + "lib/python3.13/idlelib", + "lib/python3.13/tkinter", + "lib/python3.13/test", + ): + path = version_dir / relative_path + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + for path in (version_dir / "lib" / "python3.13" / "lib-dynload").glob("_tkinter*.so"): + path.unlink() + + def create_python_packages(python: str, resources_dir: Path) -> None: python_root = resources_dir / "Python" - if python_root.exists(): - shutil.rmtree(python_root) - python_root.mkdir() site_packages = python_root / "site-packages" + if site_packages.exists(): + shutil.rmtree(site_packages) + python_root.mkdir(exist_ok=True) site_packages.mkdir() major, minor = python_major_minor(python) @@ -205,9 +447,22 @@ def create_python_packages(python: str, resources_dir: Path) -> None: build_lib_existed = generated_build_lib.exists() try: run([str(build_python), "-m", "pip", "install", "--target", str(site_packages), str(REPO_ROOT)]) + run([str(build_python), "-m", "pip", "install", "--target", str(site_packages), *APP_BUNDLED_PYTHON_REQUIREMENTS]) finally: if not build_lib_existed and generated_build_lib.exists(): shutil.rmtree(generated_build_lib) + remove_optional_zeroconf_extensions(site_packages) + + +def remove_optional_zeroconf_extensions(site_packages: Path) -> None: + # zeroconf's Cython modules are optional. PyPI currently publishes arm64-only + # macOS wheels for CPython 3.9, so keep the app bundle portable by using the + # pure-Python modules that ship in the same package. + zeroconf = site_packages / "zeroconf" + if not zeroconf.is_dir(): + return + for extension in zeroconf.rglob("*.so"): + extension.unlink() def copy_distribution(resources_dir: Path) -> None: @@ -237,25 +492,130 @@ def assert_distribution_artifacts(distribution: Path) -> None: raise RuntimeError(f"Bundled distribution is missing payload artifact(s):\n - {joined}") -def copy_tool(name: str, tools_bin: Path) -> bool: - source = shutil.which(name) - if not source: - return False - destination = tools_bin / name +def macho_architectures(path: Path) -> set[str]: + completed = subprocess.run( + ["lipo", "-archs", str(path)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + return set() + return set(completed.stdout.strip().split()) + + +def tool_env_names(name: str, architecture: str) -> list[str]: + tool = name.upper().replace("-", "_") + arch = architecture.upper().replace("-", "_") + return [ + f"TCAPSULE_PACKAGE_{tool}_{arch}", + f"TCAPSULE_PACKAGE_{tool}", + ] + + +def unique_paths(paths: list[Path]) -> list[Path]: + result: list[Path] = [] + seen: set[Path] = set() + for path in paths: + resolved = path.expanduser() + if resolved in seen: + continue + seen.add(resolved) + result.append(resolved) + return result + + +def tool_candidates(name: str, architecture: str) -> list[Path]: + paths: list[Path] = [] + for env_name in tool_env_names(name, architecture): + value = os.getenv(env_name) + if value: + paths.append(Path(value)) + + preferred_prefixes = { + "arm64": [Path("/opt/homebrew/bin")], + "x86_64": [Path("/usr/local/bin")], + } + paths.extend(prefix / name for prefix in preferred_prefixes.get(architecture, ())) + if found := shutil.which(name): + paths.append(Path(found)) + paths.extend([ + Path("/opt/homebrew/bin") / name, + Path("/usr/local/bin") / name, + ]) + return unique_paths(paths) + + +def find_tool_for_architecture(name: str, architecture: str) -> Path | None: + for candidate in tool_candidates(name, architecture): + if not candidate.is_file() or not os.access(candidate, os.X_OK): + continue + if architecture in macho_architectures(candidate): + return candidate + return None + + +def copy_arch_tool(source: Path, tools_bin: Path, name: str, architecture: str) -> None: + destination = tools_bin / architecture / name + destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) destination.chmod(0o755) - return True -def copy_tools(resources_dir: Path, require_tools: bool) -> None: +def write_tool_arch_wrapper(tools_bin: Path, name: str, architectures: tuple[str, ...]) -> None: + cases = "\n".join( + f" {architecture}) exec \"$tool_dir/{architecture}/{name}\" \"$@\" ;;" + for architecture in architectures + ) + wrapper = tools_bin / name + wrapper.write_text( + f"""#!/bin/sh +set -eu +tool_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +arch="$(/usr/bin/uname -m)" +case "$arch" in +{cases} +esac +echo "{name} is not bundled for architecture $arch" >&2 +exit 127 +""", + encoding="utf-8", + ) + wrapper.chmod(0o755) + + +def copy_tools(resources_dir: Path, architectures: tuple[str, ...]) -> 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: + missing: list[str] = [] + + if len(architectures) == 1: + architecture = architectures[0] + for tool in REQUIRED_HOST_TOOLS: + source = find_tool_for_architecture(tool, architecture) + if source is None: + missing.append(f"{tool} ({architecture})") + continue + destination = tools_bin / tool + shutil.copy2(source, destination) + destination.chmod(0o755) + else: + for tool in REQUIRED_HOST_TOOLS: + copied_architectures: list[str] = [] + for architecture in architectures: + source = find_tool_for_architecture(tool, architecture) + if source is None: + missing.append(f"{tool} ({architecture})") + continue + copy_arch_tool(source, tools_bin, tool, architecture) + copied_architectures.append(architecture) + if copied_architectures: + write_tool_arch_wrapper(tools_bin, tool, tuple(copied_architectures)) + + if missing: 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 macho_dependencies(path: Path) -> list[str] | None: @@ -295,8 +655,26 @@ def is_external_macho_dependency(dependency: str) -> bool: return dependency.startswith("/") and not is_system_macho_dependency(dependency) -def bundled_dependency_name(source: Path, used_names: set[str]) -> str: - name = source.name +def is_inside(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def resolve_macho_dependency(loader: Path, app: Path, dependency: str) -> Path | None: + if dependency.startswith("@loader_path/"): + return (loader.parent / dependency.removeprefix("@loader_path/")).resolve() + if dependency.startswith("@executable_path/"): + return (app / "Contents" / "MacOS" / dependency.removeprefix("@executable_path/")).resolve() + if dependency.startswith("/"): + return Path(dependency).resolve() + return None + + +def bundled_dependency_name(source: Path, used_names: set[str], *, preferred_name: str | None = None) -> str: + name = preferred_name or source.name if name not in used_names: used_names.add(name) return name @@ -315,7 +693,8 @@ def loader_path_reference(loader: Path, dependency: Path, frameworks_dir: Path) def is_library_like_macho(path: Path) -> bool: - return path.suffix in {".dylib", ".so"} or ".framework" in path.parts or path.parent.name in {"lib", "private"} + framework_binary = path.parent.name.endswith(".framework") and path.name == path.parent.stem + return path.suffix in {".dylib", ".so"} or framework_binary or path.parent.name in {"lib", "private"} def set_macho_id_if_supported(path: Path) -> None: @@ -335,14 +714,26 @@ def files_under(roots: list[Path]) -> list[Path]: if not root.exists(): continue for path in root.rglob("*"): + if path.is_symlink(): + continue if path.is_file(): candidates.append(path) return candidates +def is_macho_candidate(path: Path) -> bool: + if path.suffix == ".a": + return False + return path.name == "Python" or path.suffix in {".dylib", ".so"} or os.access(path, os.X_OK) + + +def macho_files_under(roots: list[Path]) -> list[Path]: + return [path for path in files_under(roots) if is_macho_candidate(path)] + + def macho_vendor_roots(app: Path) -> list[Path]: contents = app / "Contents" - return files_under([ + return macho_files_under([ contents / "Resources" / "Tools" / "bin", contents / "Frameworks", ]) @@ -350,18 +741,58 @@ def macho_vendor_roots(app: Path) -> list[Path]: def macho_validation_roots(app: Path) -> list[Path]: contents = app / "Contents" - return files_under([ + return macho_files_under([ contents / "MacOS", contents / "Resources" / "Tools" / "bin", + contents / "Resources" / "Python" / "Runtime", contents / "Resources" / "Python" / "site-packages", contents / "Frameworks", ]) +def ad_hoc_codesign(path: Path) -> None: + run_quiet(["codesign", "--force", "--sign", "-", str(path)]) + + +def codesign_order(path: Path, app: Path) -> tuple[int, str]: + try: + relative = path.resolve().relative_to(app.resolve()) + except ValueError: + return (50, str(path)) + parts = relative.parts + if len(parts) >= 3 and parts[:2] == ("Contents", "MacOS"): + return (90, str(path)) + if len(parts) >= 2 and parts[:2] == ("Contents", "Frameworks"): + return (10, str(path)) + return (20, str(path)) + + +def should_codesign_packaged_macho(path: Path, app: Path) -> bool: + try: + relative = path.resolve().relative_to(app.resolve()) + except ValueError: + return False + parts = relative.parts + return len(parts) >= 2 and parts[:2] in { + ("Contents", "Frameworks"), + ("Contents", "Resources"), + } + + +def ad_hoc_codesign_macho_bundle(app: Path) -> None: + for path in sorted(macho_validation_roots(app), key=lambda candidate: codesign_order(candidate, app)): + if should_codesign_packaged_macho(path, app) and macho_architectures(path): + ad_hoc_codesign(path) + framework = bundled_python_framework(app) + if framework.is_dir(): + ad_hoc_codesign(framework) + + def vendor_macho_dependencies(app: Path) -> None: frameworks_dir = app / "Contents" / "Frameworks" frameworks_dir.mkdir() source_to_bundle: dict[Path, Path] = {} + bundle_to_source: dict[Path, Path] = {} used_names: set[str] = set() queue = macho_vendor_roots(app) visited: set[Path] = set() @@ -378,17 +809,31 @@ def vendor_macho_dependencies(app: Path) -> None: continue for dependency in dependencies: - if not is_external_macho_dependency(dependency): + preferred_name: str + if is_external_macho_dependency(dependency): + source_path = Path(dependency) + source = source_path.resolve() + preferred_name = source_path.name + elif dependency.startswith("@loader_path/") and current_resolved in bundle_to_source: + relative_dependency = dependency.removeprefix("@loader_path/") + source = (bundle_to_source[current_resolved].parent / relative_dependency).resolve() + preferred_name = Path(relative_dependency).name + if not source.is_file(): + resolved_dependency = resolve_macho_dependency(current, app, dependency) + if resolved_dependency is None or not resolved_dependency.exists(): + raise RuntimeError(f"Mach-O dependency does not exist: {dependency} referenced by {current}") + continue + else: continue - source = Path(dependency).resolve() if not source.is_file(): raise RuntimeError(f"Mach-O dependency does not exist: {dependency} referenced by {current}") bundled = source_to_bundle.get(source) if bundled is None: - bundled = frameworks_dir / bundled_dependency_name(source, used_names) + bundled = frameworks_dir / bundled_dependency_name(source, used_names, preferred_name=preferred_name) shutil.copy2(source, bundled) bundled.chmod(bundled.stat().st_mode | 0o200) source_to_bundle[source] = bundled + bundle_to_source[bundled.resolve()] = source queue.append(bundled) run_quiet([ "install_name_tool", @@ -401,6 +846,29 @@ def vendor_macho_dependencies(app: Path) -> None: set_macho_id_if_supported(current) +def assert_macho_code_signatures_valid(app: Path) -> None: + failures: list[str] = [] + for path in macho_validation_roots(app): + if not should_codesign_packaged_macho(path, app): + continue + if not macho_architectures(path): + continue + completed = subprocess.run( + ["codesign", "--verify", "--verbose=4", str(path)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip().splitlines() + reason = detail[-1] if detail else f"codesign verification failed with rc={completed.returncode}" + failures.append(f"{path}: {reason}") + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"App bundle contains invalid Mach-O code signature(s):\n - {joined}") + + def assert_no_external_macho_dependencies(app: Path) -> None: external: list[str] = [] for path in macho_validation_roots(app): @@ -418,16 +886,21 @@ def assert_no_external_macho_dependencies(app: Path) -> None: def assert_python_dependencies_are_bundled(app: Path) -> None: env = os.environ.copy() site_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + python_home = bundled_python_home(app) env["PYTHONPATH"] = str(site_packages) env["PYTHONNOUSERSITE"] = "1" + env["PYTHONDONTWRITEBYTECODE"] = "1" + env["PYTHONHOME"] = str(python_home) code = ( - "import Crypto, ifaddr, pexpect, timecapsulesmb, zeroconf, zopfli.gzip\n" - "paths = [Crypto.__file__, ifaddr.__file__, pexpect.__file__, timecapsulesmb.__file__, zeroconf.__file__, zopfli.__file__]\n" - "bad = [p for p in paths if not p or '/Contents/Resources/Python/site-packages/' not in p]\n" + "from pathlib import Path\n" + "import certifi, Crypto, ifaddr, pexpect, timecapsulesmb, zeroconf, zopfli.gzip\n" + f"site = Path({str(site_packages)!r}).resolve()\n" + "paths = [certifi.__file__, Crypto.__file__, ifaddr.__file__, pexpect.__file__, timecapsulesmb.__file__, zeroconf.__file__, zopfli.__file__]\n" + "bad = [p for p in paths if not p or not Path(p).resolve().is_relative_to(site)]\n" "raise SystemExit('\\n'.join(bad) if bad else 0)\n" ) completed = subprocess.run( - ["/usr/bin/python3", "-c", code], + [str(bundled_python_executable(app)), "-c", code], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -435,7 +908,163 @@ def assert_python_dependencies_are_bundled(app: Path) -> None: check=False, ) if completed.returncode != 0: - raise RuntimeError(f"Bundled Python dependencies are not importable from the app package:\n{completed.stderr}") + raise RuntimeError( + "Bundled Python dependencies are not importable from the app package:\n" + f"stdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}" + ) + + +def bundled_ca_certificate_path(app: Path) -> Path: + return app / "Contents" / "Resources" / "Python" / "site-packages" / "certifi" / "cacert.pem" + + +def assert_bundled_ca_certificates(app: Path) -> None: + ca_certificates = bundled_ca_certificate_path(app) + if not ca_certificates.is_file(): + raise RuntimeError(f"App bundle is missing bundled CA certificates: {ca_certificates}") + + +def assert_macho_has_architectures(path: Path, architectures: tuple[str, ...], label: str) -> None: + actual = macho_architectures(path) + missing = [architecture for architecture in architectures if architecture not in actual] + if missing: + raise RuntimeError( + f"{label} is missing architecture(s) {', '.join(missing)}: {path} " + f"(found: {', '.join(sorted(actual)) or 'none'})" + ) + + +def assert_python_extension_architectures(app: Path, architectures: tuple[str, ...]) -> None: + site_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + failures: list[str] = [] + for path in site_packages.rglob("*"): + if not path.is_file() or path.suffix not in {".so", ".dylib"}: + continue + actual = macho_architectures(path) + missing = [architecture for architecture in architectures if architecture not in actual] + if missing: + failures.append(f"{path}: missing {', '.join(missing)} (found: {', '.join(sorted(actual)) or 'none'})") + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"Bundled Python extension(s) are missing required architecture(s):\n - {joined}") + + +def assert_tool_architectures(app: Path, architectures: tuple[str, ...]) -> None: + tools_bin = app / "Contents" / "Resources" / "Tools" / "bin" + failures: list[str] = [] + for tool in REQUIRED_HOST_TOOLS: + if len(architectures) == 1: + candidate = tools_bin / tool + if not candidate.is_file() or not os.access(candidate, os.X_OK): + failures.append(f"{candidate}: missing") + continue + if candidate.is_file() and macho_architectures(candidate): + missing = [architecture for architecture in architectures if architecture not in macho_architectures(candidate)] + if missing: + failures.append(f"{candidate}: missing {', '.join(missing)}") + continue + + wrapper = tools_bin / tool + if not wrapper.is_file() or not os.access(wrapper, os.X_OK): + failures.append(f"{wrapper}: missing architecture dispatch wrapper") + for architecture in architectures: + candidate = tools_bin / architecture / tool + if not candidate.is_file() or not os.access(candidate, os.X_OK): + failures.append(f"{candidate}: missing") + continue + if architecture not in macho_architectures(candidate): + failures.append( + f"{candidate}: missing {architecture} " + f"(found: {', '.join(sorted(macho_architectures(candidate))) or 'none'})" + ) + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"Bundled tool architecture validation failed:\n - {joined}") + + +def runtime_architecture_roots(app: Path, architectures: tuple[str, ...]) -> list[tuple[Path, str]]: + contents = app / "Contents" + roots: list[tuple[Path, str]] = [] + executable = contents / "MacOS" / PRODUCT_NAME + roots.extend((executable, architecture) for architecture in architectures) + python_runtime = contents / "Resources" / "Python" / "Runtime" + roots.extend( + (path, architecture) + for path in macho_files_under([python_runtime]) + for architecture in architectures + ) + + site_packages = contents / "Resources" / "Python" / "site-packages" + if site_packages.is_dir(): + for path in site_packages.rglob("*"): + if path.is_file() and path.suffix in {".so", ".dylib"}: + roots.extend((path, architecture) for architecture in architectures) + + tools_bin = contents / "Resources" / "Tools" / "bin" + for tool in REQUIRED_HOST_TOOLS: + if len(architectures) == 1: + roots.append((tools_bin / tool, architectures[0])) + continue + roots.extend((tools_bin / architecture / tool, architecture) for architecture in architectures) + return roots + + +def assert_runtime_macho_architectures(app: Path, architectures: tuple[str, ...]) -> None: + failures: list[str] = [] + queue = runtime_architecture_roots(app, architectures) + visited: set[tuple[Path, str]] = set() + + while queue: + path, architecture = queue.pop(0) + resolved_path = path.resolve() + key = (resolved_path, architecture) + if key in visited: + continue + visited.add(key) + + actual = macho_architectures(path) + if actual and architecture not in actual: + failures.append(f"{path}: missing {architecture} (found: {', '.join(sorted(actual)) or 'none'})") + continue + + dependencies = macho_dependencies(path) + if dependencies is None: + continue + for dependency in dependencies: + dependency_path = resolve_macho_dependency(path, app, dependency) + if dependency_path is None: + if is_system_macho_dependency(dependency): + continue + continue + if not is_inside(dependency_path, app): + continue + if not dependency_path.is_file(): + failures.append(f"{path}: missing bundled dependency {dependency} -> {dependency_path}") + continue + queue.append((dependency_path, architecture)) + + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"Bundled Mach-O runtime architecture validation failed:\n - {joined}") + + +def validate_app_resources(app: Path) -> None: + executable = app / "Contents" / "MacOS" / PRODUCT_NAME + completed = subprocess.run( + [str(executable), "--validate-resources"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + timeout=10, + ) + if completed.returncode != 0: + raise RuntimeError( + "App executable resource validation failed:\n" + f"stdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}" + ) def parse_helper_events(stdout: str) -> list[dict[str, object]]: @@ -473,18 +1102,38 @@ def smoke_request(helper: Path, operation: str, state_dir: Path) -> None: raise RuntimeError(f"{operation} smoke test failed:\n{completed.stdout}\n{completed.stderr}") -def assert_bundle_layout(app: Path, *, icon_name: str | None = None) -> None: +def assert_bundle_layout( + app: Path, + *, + icon_name: str | None = None, + architectures: tuple[str, ...] = (), +) -> None: + executable = app / "Contents" / "MacOS" / PRODUCT_NAME helper = app / "Contents" / "Helpers" / "tcapsule" + python_executable = bundled_python_executable(app) + python_dylib = bundled_python_dylib(app) info_plist = app / "Contents" / "Info.plist" + resource_bundle = app / "Contents" / "Resources" / RESOURCE_BUNDLE_NAME distribution = app / "Contents" / "Resources" / "Distribution" artifact_manifest = distribution / "artifact-manifest.json" tools_bin = app / "Contents" / "Resources" / "Tools" / "bin" python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" - required_executables = [helper] + required_executables = [executable, helper, python_executable] missing_executables = [path for path in required_executables if not path.is_file() or not os.access(path, os.X_OK)] if missing_executables: joined = "\n - ".join(str(path) for path in missing_executables) raise RuntimeError(f"App bundle is missing required executable(s):\n - {joined}") + if not python_dylib.is_file(): + raise RuntimeError(f"App bundle is missing bundled Python framework: {python_dylib}") + if architectures: + assert_macho_has_architectures(executable, architectures, "App executable") + assert_macho_has_architectures(python_executable, architectures, "Bundled Python executable") + assert_macho_has_architectures(python_dylib, architectures, "Bundled Python framework") + assert_python_extension_architectures(app, architectures) + assert_tool_architectures(app, architectures) + assert_runtime_macho_architectures(app, architectures) + if not (resource_bundle / "en.lproj" / "Localizable.strings").is_file(): + raise RuntimeError(f"App bundle is missing Swift resource bundle localizations: {resource_bundle}") if not python_packages.is_dir(): raise RuntimeError(f"App bundle is missing bundled Python packages: {python_packages}") if not (distribution / "bin").is_dir(): @@ -502,8 +1151,11 @@ def assert_bundle_layout(app: Path, *, icon_name: str | None = None) -> None: if info.get("CFBundleIconFile") != icon_name: raise RuntimeError(f"Info.plist does not reference app icon {icon_name}") assert_distribution_artifacts(distribution) + assert_bundled_ca_certificates(app) assert_python_dependencies_are_bundled(app) assert_no_external_macho_dependencies(app) + assert_macho_code_signatures_valid(app) + validate_app_resources(app) def smoke_test(app: Path) -> None: @@ -515,7 +1167,8 @@ def smoke_test(app: Path) -> None: def package_app(args: argparse.Namespace) -> Path: - executable = build_swift(args.configuration) + architectures = resolve_architectures(args.arch) + executable, resource_build_dir = build_swift(args.configuration, architectures) output_dir = args.output.resolve() app = output_dir / f"{APP_NAME}.app" contents = app / "Contents" @@ -532,15 +1185,18 @@ def package_app(args: argparse.Namespace) -> Path: icon_name = APP_ICON_NAME if args.icon else None write_info_plist(contents, icon_name=icon_name) shutil.copy2(executable, macos / PRODUCT_NAME) - copy_resources(args.configuration, resources) + (macos / PRODUCT_NAME).chmod(0o755) + copy_resources(resource_build_dir, resources) if args.icon: create_app_icon(args.icon.resolve(), resources) write_helper_wrapper(helpers / "tcapsule") - create_python_packages(args.python, resources) + python_executable = copy_python_runtime(args, resources, architectures) + create_python_packages(str(python_executable), resources) copy_distribution(resources) - copy_tools(resources, args.require_tools) + copy_tools(resources, architectures) vendor_macho_dependencies(app) - assert_bundle_layout(app, icon_name=icon_name) + ad_hoc_codesign_macho_bundle(app) + assert_bundle_layout(app, icon_name=icon_name, architectures=architectures) if not args.skip_smoke: smoke_test(app) @@ -551,14 +1207,35 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build a self-contained TimeCapsuleSMB.app bundle.") parser.add_argument("--output", type=Path, default=PACKAGE_ROOT / "dist", help="Directory that will receive TimeCapsuleSMB.app.") parser.add_argument("--configuration", choices=("debug", "release"), default="release", help="Swift build configuration.") + parser.add_argument( + "--arch", + action="append", + choices=("universal", "native", "arm64", "x86_64"), + help="Architecture to build; repeat for multiple architectures. Defaults to universal.", + ) parser.add_argument( "--icon", type=Path, default=DEFAULT_ICON_SOURCE, help="Source image to convert into the app bundle .icns icon.", ) - parser.add_argument("--python", default=DEFAULT_RUNTIME_PYTHON, help="Python interpreter used to build app-bundled packages; defaults to macOS /usr/bin/python3.") - parser.add_argument("--require-tools", action="store_true", help="Fail if sshpass or smbclient cannot be copied into the app bundle.") + parser.add_argument( + "--python-runtime-framework", + type=Path, + default=Path(os.environ["TCAPSULE_PACKAGE_PYTHON_FRAMEWORK"]) if os.getenv("TCAPSULE_PACKAGE_PYTHON_FRAMEWORK") else None, + help="Existing universal Python.framework to copy into the app bundle.", + ) + parser.add_argument( + "--python-runtime-pkg", + type=Path, + default=Path(os.environ["TCAPSULE_PACKAGE_PYTHON_PKG"]) if os.getenv("TCAPSULE_PACKAGE_PYTHON_PKG") else None, + help="Universal python.org macOS installer package to extract into the app bundle.", + ) + parser.add_argument( + "--python-runtime-url", + default=os.getenv("TCAPSULE_PACKAGE_PYTHON_URL", PYTHON_RUNTIME_URL), + help="Universal python.org macOS installer URL used when no local runtime source is provided.", + ) parser.add_argument("--skip-smoke", action="store_true", help="Skip bundled helper capabilities and validate-install smoke tests.") return parser.parse_args(argv) diff --git a/tests/test_macos_package_app.py b/tests/test_macos_package_app.py index 811436bd..4df34f52 100644 --- a/tests/test_macos_package_app.py +++ b/tests/test_macos_package_app.py @@ -19,6 +19,48 @@ def load_package_app_module(): return module +def create_fake_app_executable_and_resources(app: Path) -> None: + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + resource_bundle = ( + app + / "Contents" + / "Resources" + / "TimeCapsuleSMBMac_TimeCapsuleSMBApp.bundle" + / "en.lproj" + ) + executable.parent.mkdir(parents=True, exist_ok=True) + resource_bundle.mkdir(parents=True, exist_ok=True) + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + create_fake_python_runtime(app) + (resource_bundle / "Localizable.strings").write_text('"screen.readiness" = "Readiness";\n', encoding="utf-8") + + +def create_fake_python_runtime(app: Path) -> None: + python_home = ( + app + / "Contents" + / "Resources" + / "Python" + / "Runtime" + / "Python.framework" + / "Versions" + / "Current" + ) + python_home.mkdir(parents=True, exist_ok=True) + (python_home / "bin").mkdir(parents=True, exist_ok=True) + (python_home / "bin" / "python3").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + (python_home / "bin" / "python3").chmod(0o755) + (python_home / "Python").write_text("python framework", encoding="utf-8") + + +def create_fake_certifi_package(site_packages: Path) -> None: + certifi = site_packages / "certifi" + certifi.mkdir(parents=True, exist_ok=True) + (certifi / "__init__.py").write_text("def where(): return __file__\n", encoding="utf-8") + (certifi / "cacert.pem").write_text("test ca bundle\n", encoding="utf-8") + + def test_smoke_request_accepts_successful_result_event(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: package_app = load_package_app_module() calls: list[dict[str, object]] = [] @@ -81,12 +123,14 @@ def test_assert_bundle_layout_checks_helper_python_tools_and_artifacts( distribution = app / "Contents" / "Resources" / "Distribution" for directory in (helper.parent, python_packages, tools, distribution / "bin" / "payloads"): directory.mkdir(parents=True) + create_fake_app_executable_and_resources(app) helper.write_text("#!/bin/sh\n", encoding="utf-8") helper.chmod(0o755) (distribution / "artifact-manifest.json").write_text('{"artifacts":{}}', encoding="utf-8") monkeypatch.setattr(package_app, "artifact_paths", lambda: ["bin/payloads/one", "bin/payloads/two"]) monkeypatch.setattr(package_app, "assert_python_dependencies_are_bundled", lambda app: None) + create_fake_certifi_package(python_packages) (distribution / "bin" / "payloads" / "one").write_text("one", encoding="utf-8") with pytest.raises(RuntimeError, match="missing payload artifact"): @@ -106,6 +150,7 @@ def test_assert_bundle_layout_requires_artifact_manifest(tmp_path: Path) -> None distribution = app / "Contents" / "Resources" / "Distribution" for directory in (helper.parent, python_packages, tools, distribution / "bin"): directory.mkdir(parents=True) + create_fake_app_executable_and_resources(app) helper.write_text("#!/bin/sh\n", encoding="utf-8") helper.chmod(0o755) @@ -121,8 +166,445 @@ def test_assert_bundle_layout_requires_python_packages(tmp_path: Path) -> None: distribution = app / "Contents" / "Resources" / "Distribution" for directory in (helper.parent, tools, distribution / "bin"): directory.mkdir(parents=True) + create_fake_app_executable_and_resources(app) helper.write_text("#!/bin/sh\n", encoding="utf-8") helper.chmod(0o755) with pytest.raises(RuntimeError, match="missing bundled Python packages"): package_app.assert_bundle_layout(app) + + +def test_assert_bundle_layout_requires_swift_resource_bundle(tmp_path: Path) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, executable.parent, python_packages, tools, distribution / "bin"): + directory.mkdir(parents=True) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + create_fake_python_runtime(app) + (distribution / "artifact-manifest.json").write_text('{"artifacts":{}}', encoding="utf-8") + + with pytest.raises(RuntimeError, match="missing Swift resource bundle"): + package_app.assert_bundle_layout(app) + + +def test_build_swift_creates_universal_binary_with_lipo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + monkeypatch.setattr(package_app, "PACKAGE_ROOT", tmp_path) + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(cmd) + if cmd[:2] == ["swift", "build"]: + architecture = cmd[cmd.index("--triple") + 1].split("-", 1)[0] + executable = package_app.swift_build_dir("release", architecture) / "TimeCapsuleSMB" + executable.parent.mkdir(parents=True, exist_ok=True) + executable.write_text(architecture, encoding="utf-8") + executable.chmod(0o755) + if cmd and cmd[0] == "lipo": + output = Path(cmd[cmd.index("-output") + 1]) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("universal", encoding="utf-8") + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(package_app, "run", fake_run) + + executable, resource_build_dir = package_app.build_swift("release", ("arm64", "x86_64")) + + assert executable == tmp_path / ".build" / "package-app" / "release" / "TimeCapsuleSMB" + assert resource_build_dir == tmp_path / ".build" / "arm64-apple-macosx" / "release" + assert ["lipo", "-create"] == calls[-1][:2] + + +def test_remove_optional_zeroconf_extensions_keeps_pure_python_package(tmp_path: Path) -> None: + package_app = load_package_app_module() + zeroconf = tmp_path / "site-packages" / "zeroconf" + nested = zeroconf / "_services" + nested.mkdir(parents=True) + py_module = nested / "browser.py" + extension = nested / "browser.cpython-39-darwin.so" + py_module.write_text("# pure python fallback\n", encoding="utf-8") + extension.write_text("arm64 binary", encoding="utf-8") + + package_app.remove_optional_zeroconf_extensions(tmp_path / "site-packages") + + assert py_module.is_file() + assert not extension.exists() + + +def test_prune_python_runtime_removes_unused_gui_frameworks(tmp_path: Path) -> None: + package_app = load_package_app_module() + framework = tmp_path / "Python.framework" + version = framework / "Versions" / "3.13" + current = framework / "Versions" / "Current" + (version / "bin").mkdir(parents=True) + (version / "Python").write_text("python", encoding="utf-8") + (version / "bin" / "python3-intel64").write_text("intel shim", encoding="utf-8") + dynload = version / "lib" / "python3.13" / "lib-dynload" + dynload.mkdir(parents=True) + (dynload / "_tkinter.cpython-313-darwin.so").write_text("tk", encoding="utf-8") + for relative in ( + "Frameworks/Tcl.framework", + "Frameworks/Tk.framework", + "lib/tcl8.6", + "lib/tk8.6", + "lib/python3.13/idlelib", + "lib/python3.13/tkinter", + "lib/python3.13/test", + ): + (version / relative).mkdir(parents=True) + current.symlink_to(version) + + package_app.prune_python_runtime(framework) + + assert not (version / "bin" / "python3-intel64").exists() + assert not (version / "Frameworks" / "Tk.framework").exists() + assert not (version / "lib" / "python3.13" / "tkinter").exists() + assert not (dynload / "_tkinter.cpython-313-darwin.so").exists() + + +def test_package_args_do_not_allow_missing_bundled_tools() -> None: + package_app = load_package_app_module() + + assert not hasattr(package_app.parse_args([]), "require_tools") + with pytest.raises(SystemExit): + package_app.parse_args(["--allow-missing-tools"]) + + +def test_helper_wrapper_uses_bundled_python_runtime(tmp_path: Path) -> None: + package_app = load_package_app_module() + helper = tmp_path / "tcapsule" + + package_app.write_helper_wrapper(helper) + + text = helper.read_text(encoding="utf-8") + assert "Python/Runtime/Python.framework/Versions/Current" in text + assert 'PYTHON="$PYTHON_HOME/bin/python3"' in text + assert 'export PYTHONHOME="$PYTHON_HOME"' in text + assert "certifi/cacert.pem" in text + assert "SSL_CERT_FILE" in text + assert "PYTHONDONTWRITEBYTECODE=1" in text + assert "/usr/bin/python3" not in text + + +def test_assert_bundle_layout_requires_bundled_ca_certificates( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, python_packages, tools, distribution / "bin"): + directory.mkdir(parents=True) + create_fake_app_executable_and_resources(app) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + (distribution / "artifact-manifest.json").write_text('{"artifacts":{}}', encoding="utf-8") + monkeypatch.setattr(package_app, "artifact_paths", lambda: []) + + with pytest.raises(RuntimeError, match="missing bundled CA certificates"): + package_app.assert_bundle_layout(app) + + +def test_copy_tools_creates_arch_dispatch_wrappers(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + sources = tmp_path / "sources" + sources.mkdir() + for tool in ("sshpass", "smbclient"): + for architecture in ("arm64", "x86_64"): + source = sources / f"{tool}-{architecture}" + source.write_text(tool, encoding="utf-8") + source.chmod(0o755) + monkeypatch.setenv(f"TCAPSULE_PACKAGE_{tool.upper()}_{architecture.upper()}", str(source)) + + def fake_architectures(path: Path) -> set[str]: + if str(path).endswith("-arm64"): + return {"arm64"} + if str(path).endswith("-x86_64"): + return {"x86_64"} + return set() + + monkeypatch.setattr(package_app, "macho_architectures", fake_architectures) + monkeypatch.setattr(package_app.shutil, "which", lambda name: None) + + resources = tmp_path / "Resources" + package_app.copy_tools(resources, ("arm64", "x86_64")) + + tools_bin = resources / "Tools" / "bin" + assert "arm64) exec" in (tools_bin / "sshpass").read_text(encoding="utf-8") + assert "x86_64) exec" in (tools_bin / "smbclient").read_text(encoding="utf-8") + assert (tools_bin / "arm64" / "sshpass").is_file() + assert (tools_bin / "x86_64" / "smbclient").is_file() + + +def test_copy_tools_requires_each_architecture_when_requested(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + arm_sshpass = tmp_path / "sshpass-arm64" + arm_sshpass.write_text("sshpass", encoding="utf-8") + arm_sshpass.chmod(0o755) + monkeypatch.setenv("TCAPSULE_PACKAGE_SSHPASS_ARM64", str(arm_sshpass)) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"} if path == arm_sshpass else set()) + monkeypatch.setattr(package_app.shutil, "which", lambda name: None) + + with pytest.raises(RuntimeError, match=r"sshpass \(x86_64\).*smbclient \(arm64\).*smbclient \(x86_64\)"): + package_app.copy_tools(tmp_path / "Resources", ("arm64", "x86_64")) + + +def test_vendor_macho_dependencies_rewrites_loader_path_to_matching_source_copy( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + arm_tool = tools / "arm64" / "smbclient" + x86_tool = tools / "x86_64" / "smbclient" + for tool in (arm_tool, x86_tool): + tool.parent.mkdir(parents=True, exist_ok=True) + tool.write_text("tool", encoding="utf-8") + tool.chmod(0o755) + + sources = tmp_path / "sources" + arm_i18n = sources / "arm64" / "libicui18n.78.dylib" + arm_icuuc = sources / "arm64" / "libicuuc.78.dylib" + arm_icudata = sources / "arm64" / "libicudata.78.dylib" + x86_i18n = sources / "x86_64" / "libicui18n.78.dylib" + x86_icuuc = sources / "x86_64" / "libicuuc.78.dylib" + x86_icudata = sources / "x86_64" / "libicudata.78.dylib" + for source in (arm_i18n, arm_icuuc, arm_icudata, x86_i18n, x86_icuuc, x86_icudata): + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text(source.parent.name, encoding="utf-8") + + def fake_dependencies(path: Path) -> list[str] | None: + resolved = path.resolve() + if resolved == arm_tool.resolve(): + return [str(arm_i18n)] + if resolved == x86_tool.resolve(): + return [str(x86_i18n)] + if path.name.startswith("libicui18n"): + return ["@loader_path/libicuuc.78.dylib", "@loader_path/libicudata.78.dylib"] + if path.name.startswith("libicuuc"): + return ["@loader_path/libicudata.78.dylib"] + return [] + + changes: list[list[str]] = [] + + def fake_run_quiet(cmd: list[str]) -> subprocess.CompletedProcess[str]: + changes.append(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(package_app, "macho_dependencies", fake_dependencies) + monkeypatch.setattr(package_app, "run_quiet", fake_run_quiet) + monkeypatch.setattr(package_app, "set_macho_id_if_supported", lambda path: None) + + package_app.vendor_macho_dependencies(app) + + frameworks = app / "Contents" / "Frameworks" + assert (frameworks / "libicuuc.78.dylib").is_file() + x86_icuuc_bundle = next(frameworks.glob("libicuuc-*.78.dylib")) + x86_icudata_bundle = next(frameworks.glob("libicudata-*.78.dylib")) + x86_i18n_bundle = next(frameworks.glob("libicui18n-*.78.dylib")) + + assert any( + cmd[0:3] == ["install_name_tool", "-change", "@loader_path/libicuuc.78.dylib"] + and cmd[3] == f"@loader_path/{x86_icuuc_bundle.name}" + and cmd[-1] == str(x86_i18n_bundle) + for cmd in changes + ) + assert any( + cmd[0:3] == ["install_name_tool", "-change", "@loader_path/libicudata.78.dylib"] + and cmd[3] == f"@loader_path/{x86_icudata_bundle.name}" + and cmd[-1] == str(x86_icuuc_bundle) + for cmd in changes + ) + + +def test_ad_hoc_codesign_macho_bundle_signs_only_macho_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + macho = app / "Contents" / "Resources" / "Tools" / "bin" / "smbclient" + script = tmp_path / "wrapper" + macho.parent.mkdir(parents=True) + macho.write_text("macho", encoding="utf-8") + script.write_text("#!/bin/sh\n", encoding="utf-8") + calls: list[list[str]] = [] + + monkeypatch.setattr(package_app, "macho_validation_roots", lambda app: [macho, script]) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"} if path == macho else set()) + + def fake_run_quiet(cmd: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(package_app, "run_quiet", fake_run_quiet) + + package_app.ad_hoc_codesign_macho_bundle(app) + + assert calls == [["codesign", "--force", "--sign", "-", str(macho)]] + + +def test_ad_hoc_codesign_macho_bundle_does_not_sign_app_executable_as_nested_code( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + library = app / "Contents" / "Frameworks" / "libtool.dylib" + tool = app / "Contents" / "Resources" / "Tools" / "bin" / "smbclient" + for path in (executable, library, tool): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("macho", encoding="utf-8") + calls: list[Path] = [] + + monkeypatch.setattr(package_app, "macho_validation_roots", lambda app: [executable, tool, library]) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"}) + monkeypatch.setattr(package_app, "ad_hoc_codesign", lambda path: calls.append(path)) + + package_app.ad_hoc_codesign_macho_bundle(app) + + assert executable not in calls + assert library in calls + assert tool in calls + + +def test_ad_hoc_codesign_macho_bundle_signs_python_framework_last( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + python_binary = app / "Contents" / "Resources" / "Python" / "Runtime" / "Python.framework" / "Versions" / "3.13" / "Python" + framework = app / "Contents" / "Resources" / "Python" / "Runtime" / "Python.framework" + python_binary.parent.mkdir(parents=True) + python_binary.write_text("python", encoding="utf-8") + calls: list[Path] = [] + + monkeypatch.setattr(package_app, "macho_validation_roots", lambda app: [python_binary]) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"}) + monkeypatch.setattr(package_app, "ad_hoc_codesign", lambda path: calls.append(path)) + + package_app.ad_hoc_codesign_macho_bundle(app) + + assert calls == [python_binary, framework] + + +def test_assert_macho_code_signatures_valid_reports_invalid_signature( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + macho = app / "Contents" / "Resources" / "Tools" / "bin" / "smbclient" + macho.parent.mkdir(parents=True) + macho.write_text("macho", encoding="utf-8") + + monkeypatch.setattr(package_app, "macho_validation_roots", lambda app: [macho]) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"}) + + def fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="invalid signature\n") + + monkeypatch.setattr(package_app.subprocess, "run", fake_run) + + with pytest.raises(RuntimeError, match="invalid Mach-O code signature"): + package_app.assert_macho_code_signatures_valid(app) + + +def test_macho_files_under_skips_symlink_aliases(tmp_path: Path) -> None: + package_app = load_package_app_module() + root = tmp_path / "root" + root.mkdir() + real = root / "libcrypto.3.dylib" + alias = root / "libcrypto.dylib" + real.write_text("macho", encoding="utf-8") + alias.symlink_to(real.name) + + paths = package_app.macho_files_under([root]) + + assert real in paths + assert alias not in paths + + +def test_runtime_macho_architecture_validation_checks_internal_dependencies( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + dependency = app / "Contents" / "Frameworks" / "libtool.dylib" + executable.parent.mkdir(parents=True) + dependency.parent.mkdir(parents=True) + executable.write_text("app", encoding="utf-8") + dependency.write_text("dependency", encoding="utf-8") + + def fake_architectures(path: Path) -> set[str]: + if path.resolve() == executable.resolve(): + return {"arm64", "x86_64"} + if path.resolve() == dependency.resolve(): + return {"arm64"} + return set() + + def fake_dependencies(path: Path) -> list[str] | None: + if path.resolve() == executable.resolve(): + return ["@loader_path/../Frameworks/libtool.dylib"] + return [] + + monkeypatch.setattr(package_app, "macho_architectures", fake_architectures) + monkeypatch.setattr(package_app, "macho_dependencies", fake_dependencies) + + with pytest.raises(RuntimeError, match=r"libtool\.dylib: missing x86_64"): + package_app.assert_runtime_macho_architectures(app, ("arm64", "x86_64")) + + +def test_python_dependency_validation_uses_bundled_python( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + create_fake_app_executable_and_resources(app) + site_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + site_packages.mkdir(parents=True) + calls: list[tuple[list[str], dict[str, str]]] = [] + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((cmd, kwargs["env"])) # type: ignore[index] + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(package_app.subprocess, "run", fake_run) + + package_app.assert_python_dependencies_are_bundled(app) + + assert calls + cmd, env = calls[0] + assert cmd[0] == str(package_app.bundled_python_executable(app)) + assert env["PYTHONHOME"] == str(package_app.bundled_python_home(app)) + assert env["PYTHONPATH"] == str(site_packages) + assert env["PYTHONDONTWRITEBYTECODE"] == "1" + + +def test_validate_app_resources_rejects_swift_resource_bundle_crash(tmp_path: Path) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + executable.parent.mkdir(parents=True) + executable.write_text("#!/bin/sh\necho resource crash >&2\nexit 70\n", encoding="utf-8") + executable.chmod(0o755) + + with pytest.raises(RuntimeError, match="App executable resource validation failed"): + package_app.validate_app_resources(app) From b96991a5ab40a8fc8b65075bdbb85702f945cc35 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 22:17:29 -0700 Subject: [PATCH 045/129] Remove NetBSD4 badge --- .../Views/Shell/SidebarView.swift | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift index f275c86d..2b5b50ae 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift @@ -23,30 +23,12 @@ struct DeviceSidebarRow: View { .foregroundStyle(.secondary) } Spacer(minLength: 6) - if let compatibilityBadge { - Text(compatibilityBadge) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(Color.secondary.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } Image(systemName: summary.displayStatus.systemImage) .foregroundStyle(statusColor) .help(summary.displayStatus.title) } } - private var compatibilityBadge: String? { - let payloadFamily = profile.payloadFamily?.lowercased() ?? "" - let osRelease = profile.osRelease ?? "" - if payloadFamily.contains("netbsd4") || osRelease.hasPrefix("4.") { - return "NetBSD 4" - } - return nil - } - private var statusColor: Color { switch summary.displayStatus { case .healthy: From 7d6a76ade56a85013714127b51ad485b6988844a Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 22:49:38 -0700 Subject: [PATCH 046/129] Update version --- pyproject.toml | 2 +- src/timecapsulesmb/core/release.py | 6 +++--- version.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c06d1ed6..9c5552dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "timecapsulesmb" -version = "2.1.5" +version = "2.2.0" description = "Deploy modern Samba to Apple AirPort Time Capsules." readme = "README.md" requires-python = ">=3.9" diff --git a/src/timecapsulesmb/core/release.py b/src/timecapsulesmb/core/release.py index ea322bca..87d596de 100644 --- a/src/timecapsulesmb/core/release.py +++ b/src/timecapsulesmb/core/release.py @@ -1,7 +1,7 @@ from __future__ import annotations # Update this version info for each release, including beta releases. -CLI_VERSION = "2.1.5" -RELEASE_TAG = "v2.1.5" -CLI_VERSION_CODE = 20126 +CLI_VERSION = "2.2.0" +RELEASE_TAG = "v2.2.0-alpha1" +CLI_VERSION_CODE = 20200 SAMBA_VERSION = "4.24.1" diff --git a/version.json b/version.json index 5addfe80..ee764b35 100644 --- a/version.json +++ b/version.json @@ -1,8 +1,8 @@ { "schema": 1, - "current_version": 20126, + "current_version": 20200, "min_supported_version": 20121, - "latest_tag": "v2.1.5", + "latest_tag": "v2.2.0", "download_url": "https://github.com/jamesyc/TimeCapsuleSMB/releases/latest", "message": "This version is no longer supported. Please update before continuing. Version v2.0.7 and earlier have known security issues." } From fe0a876cfc3279b28b7fb20593d330ad05a2a9a5 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 23:08:29 -0700 Subject: [PATCH 047/129] Fix non mac CI test --- tests/test_macos_package_app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_macos_package_app.py b/tests/test_macos_package_app.py index 4df34f52..dcb7e42f 100644 --- a/tests/test_macos_package_app.py +++ b/tests/test_macos_package_app.py @@ -130,6 +130,11 @@ def test_assert_bundle_layout_checks_helper_python_tools_and_artifacts( monkeypatch.setattr(package_app, "artifact_paths", lambda: ["bin/payloads/one", "bin/payloads/two"]) monkeypatch.setattr(package_app, "assert_python_dependencies_are_bundled", lambda app: None) + # This synthetic bundle-layout test should stay portable across the CI + # matrix. Dedicated tests below cover the macOS Mach-O validators directly. + monkeypatch.setattr(package_app, "assert_no_external_macho_dependencies", lambda app: None) + monkeypatch.setattr(package_app, "assert_macho_code_signatures_valid", lambda app: None) + monkeypatch.setattr(package_app, "validate_app_resources", lambda app: None) create_fake_certifi_package(python_packages) (distribution / "bin" / "payloads" / "one").write_text("one", encoding="utf-8") From 18f4ce8f9efc28ad2ff20cad708f00c535a0a8f3 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 26 May 2026 23:12:30 -0700 Subject: [PATCH 048/129] Invalidate stale checkup state when device mutations start --- .../Policies/DeviceStatusPolicy.swift | 3 + .../Profiles/DeviceRegistryStore.swift | 24 ++++++- .../Workflows/DeviceDashboardSession.swift | 34 ++++++++-- .../Workflows/DoctorStore.swift | 9 +++ .../DashboardStoreTests.swift | 66 ++++++++++++++++++- .../DeviceStatusPolicyTests.swift | 6 +- 6 files changed, 132 insertions(+), 10 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift index 681feb75..381cbc4a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift @@ -115,6 +115,9 @@ enum DeviceStatusPolicy { } guard let checkup = profile.lastCheckup else { + if let deploy = profile.lastDeploy, deploy.state == .deployed { + return deploy.verified == true ? .healthy : .warning + } return .unchecked } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift index 493ea88c..a9ac6756 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift @@ -176,6 +176,12 @@ final class DeviceRegistryStore: ObservableObject { } } + func clearCheckup(for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.clearCheckup(for: profileID) + } + } + func updateDeploy(_ snapshot: DeviceDeploySnapshot, for profileID: DeviceProfile.ID) async { await applyBackgroundMutation { try await repository.updateDeploy(snapshot, for: profileID) @@ -431,6 +437,21 @@ private actor DeviceRegistryRepository { return updatedProfiles } + func clearCheckup(for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return nil + } + guard profiles[index].lastCheckup != nil else { + return nil + } + var updatedProfiles = profiles + updatedProfiles[index].lastCheckup = nil + updatedProfiles[index].updatedAt = now() + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles + } + func updateDeploy(_ snapshot: DeviceDeploySnapshot, for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { return nil @@ -447,11 +468,12 @@ private actor DeviceRegistryRepository { guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { return nil } - guard profiles[index].lastDeploy != nil else { + guard profiles[index].lastDeploy != nil || profiles[index].lastCheckup != nil else { return nil } var updatedProfiles = profiles updatedProfiles[index].lastDeploy = nil + updatedProfiles[index].lastCheckup = nil updatedProfiles[index].updatedAt = now() try persist(updatedProfiles) profiles = updatedProfiles diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index 810f3b84..d09f75c4 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -134,7 +134,8 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } case .runActivation: if let password = maintenancePassword(for: profile) { - maintenanceStore.runActivation(password: password, profile: profile) + let start = maintenanceStore.runActivation(password: password, profile: profile) + invalidateCheckupIfStarted(start) } case .planUninstall: if let password = maintenancePassword(for: profile) { @@ -142,9 +143,11 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } case .runUninstall: if let password = maintenancePassword(for: profile) { - if case .started(let operation) = maintenanceStore.runUninstall(password: password, profile: profile) { + let start = maintenanceStore.runUninstall(password: password, profile: profile) + if case .started(let operation) = start { activeUninstallOperation = operation } + invalidateCheckupIfStarted(start) } case .findVolumes: if let password = maintenancePassword(for: profile) { @@ -156,7 +159,8 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } case .runFsck: if let password = maintenancePassword(for: profile) { - maintenanceStore.runFsck(password: password, profile: profile) + let start = maintenanceStore.runFsck(password: password, profile: profile) + invalidateCheckupIfStarted(start) } case .scanMetadata: selectedTab = .maintenance @@ -185,11 +189,13 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { flashStore.planFlash(mode: .downloadOnly, profile: profile) case .writePatch: if let password = maintenancePassword(for: profile) { - flashStore.write(mode: .patch, password: password, profile: profile) + let start = flashStore.write(mode: .patch, password: password, profile: profile) + invalidateCheckupIfStarted(start) } case .writeRestore: if let password = maintenancePassword(for: profile) { - flashStore.write(mode: .restore, password: password, profile: profile) + let start = flashStore.write(mode: .restore, password: password, profile: profile) + invalidateCheckupIfStarted(start) } } } @@ -230,6 +236,7 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { selectedTab = .install if case .started(let operation) = deployStore.runDeploy(password: password, profile: profile) { activeDeployOperation = operation + invalidateCheckup(for: operation) } } @@ -495,6 +502,23 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { } } + private func invalidateCheckupIfStarted(_ start: OperationStartResult) { + guard case .started(let operation) = start else { + return + } + invalidateCheckup(for: operation) + } + + private func invalidateCheckup(for operation: ActiveOperation) { + guard let profileID = operation.profileID else { + return + } + doctorStore.invalidateResult() + Task { + await appStore.deviceRegistry.clearCheckup(for: profileID) + } + } + private func updateUninstallSnapshot(state: MaintenanceOperationState) { guard [.succeeded, .failed].contains(state) else { return diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift index 87733344..817778e1 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift @@ -183,6 +183,15 @@ final class DoctorStore: ObservableObject { func clear() { backend.clear() lastProcessedEventCount = 0 + clearResultState() + } + + func invalidateResult() { + lastProcessedEventCount = backend.events.count + clearResultState() + } + + private func clearResultState() { state = .idle payload = nil summary = nil diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index b792828f..cf7c2273 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -398,7 +398,7 @@ final class DashboardStoreTests: XCTestCase { ]), .init(events: [ BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) - ]) + ], delayNanoseconds: 200_000_000) ]) let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), @@ -424,8 +424,14 @@ final class DashboardStoreTests: XCTestCase { try await waitUntilStoreState { session.deployStore.state == .planReady } session.runInstall(profile: checked) + try await waitUntilStoreState { + session.deployStore.state == .deploying + && fixture.registry.profile(id: profile.id)?.lastCheckup == nil + && session.doctorStore.summary == nil + } try await waitUntilStoreState { session.deployStore.state == .deployed } let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertNil(installed.lastCheckup) XCTAssertEqual(installed.lastDeploy?.state, .deployed) XCTAssertEqual(installed.lastDeploy?.payloadFamily, "netbsd6_samba4") XCTAssertEqual(installed.lastDeploy?.verified, true) @@ -441,7 +447,7 @@ final class DashboardStoreTests: XCTestCase { ]), .init(events: [ BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallResultPayload(waited: true, verified: true)) - ]) + ], delayNanoseconds: 200_000_000) ]) let profile = try await fixture.registry.saveConfiguredDevice( configuredDevice: testConfiguredDevice(host: "10.0.0.2"), @@ -457,6 +463,14 @@ final class DashboardStoreTests: XCTestCase { verified: true, summary: "installed" ), for: profile.id) + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 130), + state: .failed, + passCount: 1, + warnCount: 0, + failCount: 1, + summary: "failed" + ), for: profile.id) let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) try fixture.passwordStore.save("pw", for: installed.keychainAccount) let dashboard = DashboardStore(appStore: fixture.appStore) @@ -466,11 +480,59 @@ final class DashboardStoreTests: XCTestCase { try await waitUntilStoreState { session.maintenanceStore.uninstallState == .planReady } session.performMaintenanceAction(.runUninstall, profile: installed) {} + try await waitUntilStoreState { + session.maintenanceStore.uninstallState == .running + && fixture.registry.profile(id: installed.id)?.lastCheckup == nil + } try await waitUntilStoreState { session.maintenanceStore.uninstallState == .succeeded && fixture.registry.profile(id: installed.id)?.lastDeploy == nil } XCTAssertNil(fixture.registry.profile(id: installed.id)?.lastDeploy) + XCTAssertNil(fixture.registry.profile(id: installed.id)?.lastCheckup) + XCTAssertEqual(fixture.runner.calls[0].params["dry_run"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(false)) + } + + func testActivationInvalidatesCheckupWhenRunStarts() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationResultPayload(alreadyActive: false)) + ], delayNanoseconds: 200_000_000) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 130), + state: .failed, + passCount: 1, + warnCount: 0, + failCount: 1, + summary: "failed" + ), for: profile.id) + let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + try fixture.passwordStore.save("pw", for: checked.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: checked) + + session.performMaintenanceAction(.planActivation, profile: checked) {} + try await waitUntilStoreState { session.maintenanceStore.activateState == .planReady } + XCTAssertNotNil(fixture.registry.profile(id: checked.id)?.lastCheckup) + + session.performMaintenanceAction(.runActivation, profile: checked) {} + + try await waitUntilStoreState { + session.maintenanceStore.activateState == .running + && fixture.registry.profile(id: checked.id)?.lastCheckup == nil + } + try await waitUntilStoreState { session.maintenanceStore.activateState == .succeeded } XCTAssertEqual(fixture.runner.calls[0].params["dry_run"], .bool(true)) XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(false)) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift index 384bcdf2..62f8df8d 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift @@ -85,6 +85,8 @@ final class DeviceStatusPolicyTests: XCTestCase { func testHealthStatusFallsBackThroughCheckupAndDeploySnapshots() throws { XCTAssertEqual(status(try makeProfile(), .available), .unchecked) + XCTAssertEqual(status(try makeProfile(lastDeploy: deployed()), .available), .healthy) + XCTAssertEqual(status(try makeProfile(lastDeploy: deployed(verified: false)), .available), .warning) 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) @@ -188,13 +190,13 @@ final class DeviceStatusPolicyTests: XCTestCase { ) } - private func deployed() -> DeviceDeploySnapshot { + private func deployed(verified: Bool = true) -> DeviceDeploySnapshot { DeviceDeploySnapshot( deployedAt: Date(timeIntervalSince1970: 11), state: .deployed, payloadFamily: "netbsd6_samba4", rebootRequested: true, - verified: true, + verified: verified, summary: "installed" ) } From 475b766e7641dd48c10a7ab0fa78d20e2ce64abf Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 27 May 2026 01:35:57 -0700 Subject: [PATCH 049/129] Move context.py into shared services layer for GUI support --- src/timecapsulesmb/app/ops/doctor.py | 47 +++- src/timecapsulesmb/app/service.py | 2 +- src/timecapsulesmb/cli/context.py | 164 ++++++------- src/timecapsulesmb/cli/doctor.py | 322 +------------------------ src/timecapsulesmb/services/app.py | 1 + src/timecapsulesmb/services/context.py | 142 +++++++++++ src/timecapsulesmb/services/doctor.py | 160 ++++++++++-- tests/test_app_api.py | 34 +++ tests/test_telemetry.py | 2 +- 9 files changed, 424 insertions(+), 450 deletions(-) create mode 100644 src/timecapsulesmb/services/context.py diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py index 88ba9310..f7bbf603 100644 --- a/src/timecapsulesmb/app/ops/doctor.py +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -6,35 +6,64 @@ from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.core.paths import resolve_app_paths from timecapsulesmb.services.app import OperationResult, bool_param, config_path +from timecapsulesmb.services.context import OperationContext from timecapsulesmb.services.credentials import overlay_request_credentials -from timecapsulesmb.services.doctor import build_doctor_error +from timecapsulesmb.services.doctor import build_doctor_error, doctor_status_counts from timecapsulesmb.services.runtime import load_env_config, resolve_env_connection def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "doctor" - sink.stage(operation, "load_config") + context = OperationContext(operation) + + def stage(name: str) -> None: + context.set_stage(name) + sink.stage(operation, name) + + stage("load_config") config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + context.config = config app_paths = resolve_app_paths(config_path=config_path(params)) + skip_ssh = bool_param(params, "skip_ssh") + skip_bonjour = bool_param(params, "skip_bonjour") + skip_smb = bool_param(params, "skip_smb") + context.update_fields(skip_ssh=skip_ssh, skip_bonjour=skip_bonjour, skip_smb=skip_smb) connection = None - if not bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): - sink.stage(operation, "resolve_connection") + if not skip_ssh and config.has_value("TC_HOST"): + stage("resolve_connection") connection = resolve_env_connection(config, allow_empty_password=True) + context.connection = connection 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") + stage("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"), + skip_ssh=skip_ssh, + skip_bonjour=skip_bonjour, + skip_smb=skip_smb, on_result=on_result, debug_fields=debug_fields, ) + context.add_debug_fields(**debug_fields) + status_counts = doctor_status_counts(results) + context.update_fields( + fatal=fatal, + check_count=len(results), + pass_count=status_counts["PASS"], + warn_count=status_counts["WARN"], + fail_count=status_counts["FAIL"], + info_count=status_counts["INFO"], + ) error = build_doctor_error(results, debug_fields) if fatal else None - return OperationResult(not fatal, doctor_payload(fatal=fatal, results=results, error=error)) + if error: + context.set_error(error) + return OperationResult( + not fatal, + doctor_payload(fatal=fatal, results=results, error=error), + diagnostic_error=context.build_error() if fatal else None, + ) diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py index 7a7d2b53..a0fe8c8b 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -121,7 +121,7 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: sink, operation, result="success" if result.ok else "failure", - error=_payload_error(result.payload) if not result.ok else None, + error=(result.diagnostic_error or _payload_error(result.payload)) if not result.ok else None, details=telemetry_details_from_payload(operation, params, result.payload), ) return 0 if result.ok else 1 diff --git a/src/timecapsulesmb/cli/context.py b/src/timecapsulesmb/cli/context.py index 8723a75c..612907b6 100644 --- a/src/timecapsulesmb/cli/context.py +++ b/src/timecapsulesmb/cli/context.py @@ -19,8 +19,13 @@ select_payload_home_with_diagnostics_conn, wait_for_mast_volumes_conn, ) +from timecapsulesmb.services.context import ( + COMMAND_FIELD_BLACKLIST, + COMMAND_VALUE_BLACKLIST, + OperationContext, + render_operation_debug_lines, +) from timecapsulesmb.telemetry import build_device_os_version -from timecapsulesmb.telemetry.debug import debug_summary, render_debug_mapping from timecapsulesmb.telemetry.operation import ( OperationTelemetrySession, client_from_environment, @@ -39,31 +44,6 @@ from timecapsulesmb.transport.ssh import SshConnection -COMMAND_VALUE_BLACKLIST = { - "TC_PASSWORD", - # Removed naming keys may still exist in old .env files. They are - # intentionally ignored and should not appear as command inputs. - "TC_SAMBA_USER", - "TC_PAYLOAD_DIR_NAME", - "TC_MDNS_HOST_LABEL", - "TC_MDNS_INSTANCE_NAME", - "TC_NETBIOS_NAME", - # These are already first-class telemetry fields. - "TC_CONFIGURE_ID", - "TC_MDNS_DEVICE_MODEL", - "TC_AIRPORT_SYAP", -} -COMMAND_FIELD_BLACKLIST = { - # These are already first-class telemetry fields. - "configure_id", - "device_model", - "device_syap", - "device_os_version", - "device_family", - "nbns_enabled", - "reboot_was_attempted", - "device_came_back_after_reboot", -} MAST_ACP_OUTPUT_DEBUG_LIMIT = 8192 OPTIONAL_IDENTITY_PROBE_FINISH_TIMEOUT_SECONDS = 0.1 @@ -77,23 +57,6 @@ def _mast_acp_output_debug_text(raw_output: str) -> str: return f"{raw_output[:MAST_ACP_OUTPUT_DEBUG_LIMIT]}..." -def _render_connection_debug_lines(connection: SshConnection | None, values: Mapping[str, str] | None) -> list[str]: - host = None - ssh_opts = None - if connection is not None: - host = connection.host - ssh_opts = connection.ssh_opts - elif values is not None: - host = values.get("TC_HOST") or None - ssh_opts = values.get("TC_SSH_OPTS") or None - lines: list[str] = [] - if host: - lines.append(f"host={host}") - if ssh_opts: - lines.append(f"ssh_opts={ssh_opts}") - return lines - - def render_command_debug_lines( *, command_name: str, @@ -106,22 +69,17 @@ def render_command_debug_lines( debug_fields: Mapping[str, object], config: AppConfig | None = None, ) -> list[str]: - debug_values = config.values if config is not None else values - lines = ["Debug context:", f"command={command_name}"] - if stage: - lines.append(f"stage={stage}") - if config is not None: - lines.append(f"env_path={config.path}") - lines.extend(_render_connection_debug_lines(connection, debug_values)) - if debug_values is not None: - lines.extend(render_debug_mapping(debug_values, blacklist=COMMAND_VALUE_BLACKLIST)) - if preflight_error: - lines.append(f"preflight_error={preflight_error}") - lines.extend(render_debug_mapping(finish_fields, blacklist=COMMAND_FIELD_BLACKLIST)) - if probe_state is not None: - lines.extend(render_debug_mapping(debug_summary(probe_state), blacklist=COMMAND_FIELD_BLACKLIST)) - lines.extend(render_debug_mapping(debug_fields, blacklist=COMMAND_FIELD_BLACKLIST)) - return lines + return render_operation_debug_lines( + operation_name=command_name, + stage=stage, + connection=connection, + values=values, + preflight_error=preflight_error, + finish_fields=finish_fields, + probe_state=probe_state, + debug_fields=debug_fields, + config=config, + ) class CommandContext: @@ -139,21 +97,13 @@ def __init__( ) -> None: self.telemetry = telemetry self.command_name = command_name - self.values = values - self.config = config + self.operation_context = OperationContext(command_name, values=values, config=config) self.args = args self.finished_event = finished_event self.finished = False self.command_id = str(uuid.uuid4()) self.result = "failure" - self.finish_fields: dict[str, object] = {} - self.error_lines: list[str] = [] - self.preflight_error: str | None = None - self.debug_stage: str | None = None - self.debug_fields: dict[str, object] = {} - self.connection: SshConnection | None = None self.interface_probe: RemoteInterfaceProbeResult | None = None - self.probe_state: ProbedDeviceState | None = None self.compatibility: DeviceCompatibility | None = None self._optional_airport_identity_thread: threading.Thread | None = None self._optional_airport_identity: tuple[str | None, str | None] | None = None @@ -172,6 +122,54 @@ def __init__( def __enter__(self) -> "CommandContext": return self + @property + def values(self) -> Mapping[str, str] | None: + return self.operation_context.values + + @property + def config(self) -> AppConfig | None: + return self.operation_context.config + + @property + def finish_fields(self) -> dict[str, object]: + return self.operation_context.finish_fields + + @property + def error_lines(self) -> list[str]: + return self.operation_context.error_lines + + @property + def preflight_error(self) -> str | None: + return self.operation_context.preflight_error + + @preflight_error.setter + def preflight_error(self, value: str | None) -> None: + self.operation_context.preflight_error = value + + @property + def debug_stage(self) -> str | None: + return self.operation_context.debug_stage + + @property + def debug_fields(self) -> dict[str, object]: + return self.operation_context.debug_fields + + @property + def connection(self) -> SshConnection | None: + return self.operation_context.connection + + @connection.setter + def connection(self, value: SshConnection | None) -> None: + self.operation_context.connection = value + + @property + def probe_state(self) -> ProbedDeviceState | None: + return self.operation_context.probe_state + + @probe_state.setter + def probe_state(self, value: ProbedDeviceState | None) -> None: + self.operation_context.probe_state = value + def __exit__(self, exc_type: object, exc: object, _tb: object) -> bool: if exc_type is KeyboardInterrupt and self.result != "cancelled": self.result = "cancelled" @@ -214,9 +212,7 @@ def fail_with_error(self, message: str) -> None: self.set_error(message) def update_fields(self, **fields: object) -> None: - for key, value in fields.items(): - if value is not None: - self.finish_fields[key] = value + self.operation_context.update_fields(**fields) def _update_device_identity_fields(self, *, model: str | None, syap: str | None) -> None: self.update_fields(device_model=model, device_syap=syap) @@ -271,34 +267,16 @@ def optional_airport_display_name(self, *, timeout_seconds: float = 0.0) -> str: ) def set_stage(self, stage: str) -> None: - self.debug_stage = stage + self.operation_context.set_stage(stage) def add_debug_fields(self, **fields: object) -> None: - for key, value in fields.items(): - if value is not None: - self.debug_fields[key] = debug_summary(value) + self.operation_context.add_debug_fields(**fields) def set_error(self, message: str) -> None: - self.error_lines = [line.rstrip() for line in message.splitlines() if line.strip()] + self.operation_context.set_error(message) def build_error(self) -> str | None: - if not self.error_lines: - return None - return "\n".join([ - *self.error_lines, - "", - *render_command_debug_lines( - command_name=self.command_name, - stage=self.debug_stage, - connection=self.connection, - values=self.values, - preflight_error=self.preflight_error, - finish_fields=self.finish_fields, - probe_state=self.probe_state, - debug_fields=self.debug_fields, - config=self.config, - ), - ]) + return self.operation_context.build_error() def confirm_or_fail( self, diff --git a/src/timecapsulesmb/cli/doctor.py b/src/timecapsulesmb/cli/doctor.py index 92972511..8183d885 100644 --- a/src/timecapsulesmb/cli/doctor.py +++ b/src/timecapsulesmb/cli/doctor.py @@ -1,8 +1,6 @@ from __future__ import annotations import argparse -import re -from collections.abc import Mapping from typing import Optional from timecapsulesmb.checks.doctor import run_doctor_checks @@ -11,14 +9,11 @@ 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.services.doctor import build_doctor_error, doctor_status_counts from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.core.paths import resolve_app_paths -BONJOUR_INSTANCE_FAILURE_PREFIX = "no discovered _smb._tcp instance matched" - - def print_result(result: CheckResult) -> None: status = result.status if status == "PASS": @@ -28,321 +23,6 @@ def print_result(result: CheckResult) -> None: print(f"{status} {result.message}") -def _mapping_value(value: object, key: str) -> object | None: - if isinstance(value, Mapping): - return value.get(key) - return None - - -def _as_int(value: object) -> int | None: - if isinstance(value, bool): - return int(value) - if isinstance(value, int): - return value - if isinstance(value, float): - return int(value) - if isinstance(value, str): - try: - return int(value) - except ValueError: - return None - return None - - -def _as_sequence(value: object) -> list[object]: - if isinstance(value, list): - return list(value) - if isinstance(value, tuple): - return list(value) - return [] - - -def _bonjour_failure_uses_instance_match(results: list[CheckResult]) -> bool: - return any(result.status == "FAIL" and BONJOUR_INSTANCE_FAILURE_PREFIX in result.message for result in results) - - -def _expected_bonjour_instance_from_results(results: list[CheckResult]) -> str | None: - for result in results: - if result.status != "FAIL" or BONJOUR_INSTANCE_FAILURE_PREFIX not in result.message: - continue - match = re.search( - r"expected (?:device |configured )?instance (?P['\"])(?P.*?)(?P=quote)", - result.message, - ) - if match: - return match.group("name") - return None - - -def _native_dns_sd_smb_names(native_dns_sd: object) -> list[str]: - names: list[str] = [] - for browse in _as_sequence(_mapping_value(native_dns_sd, "browses")): - browse_type = str(_mapping_value(browse, "service_type") or "") - for event in _as_sequence(_mapping_value(browse, "events")): - event_type = str(_mapping_value(event, "service_type") or browse_type) - if not event_type.rstrip(".").startswith("_smb._tcp"): - continue - if str(_mapping_value(event, "action") or "").lower() != "add": - continue - name = _mapping_value(event, "name") - if isinstance(name, str) and name and name not in names: - names.append(name) - return names - - -def build_discovery_context(results: list[CheckResult], debug_fields: Mapping[str, object]) -> list[str]: - if not _bonjour_failure_uses_instance_match(results): - return [] - - lines: list[str] = [] - expected_summary, expected_instance = _bonjour_expected_summary(results, debug_fields) - if expected_summary: - lines.append(f"INFO expected Bonjour identity: {expected_summary}") - zeroconf = _mapping_value(debug_fields, "bonjour_zeroconf") - zeroconf_instance_count = _as_int(_mapping_value(zeroconf, "instance_count")) - if zeroconf_instance_count == 0: - lines.append( - "INFO Python zeroconf discovered 0 Bonjour instances during doctor; " - "mDNS advertiser/discovery path needs investigation" - ) - elif zeroconf_instance_count is not None: - lines.append( - f"INFO Python zeroconf discovered {zeroconf_instance_count} Bonjour instance(s), " - "but no matching _smb._tcp instance" - ) - if _authenticated_smb_listing_passed(debug_fields): - lines.append("INFO SMB works over unicast, but Bonjour discovered no matching _smb._tcp records") - zeroconf_summary = _zeroconf_debug_summary(zeroconf) - if zeroconf_summary: - lines.append(f"INFO Python zeroconf diagnostics: {zeroconf_summary}") - lines.extend(_mdns_transport_context_from_debug(debug_fields)) - lines.extend(_mdns_counter_context_from_debug(debug_fields)) - lines.extend(_native_dns_sd_context_from_debug(debug_fields, expected_instance=expected_instance)) - return lines - - -def _authenticated_smb_listing_passed(debug_fields: Mapping[str, object]) -> bool: - for attempt in _as_sequence(_mapping_value(debug_fields, "authenticated_smb_listing_attempts")): - outcome = _mapping_value(attempt, "outcome") - expected_share_found = _mapping_value(attempt, "expected_share_found") - if outcome == "pass" and expected_share_found is True: - return True - return False - - -def _debug_scalar_text(value: object) -> str | None: - if value is None: - return None - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (str, int, float)): - return str(value) - return None - - -def _debug_summary_fields(value: object, keys: tuple[str, ...]) -> str: - parts: list[str] = [] - for key in keys: - text = _debug_scalar_text(_mapping_value(value, key)) - if text is not None: - parts.append(f"{key}={text}") - return " ".join(parts) - - -def _bonjour_expected_summary( - results: list[CheckResult], - debug_fields: Mapping[str, object], -) -> tuple[str, str | None]: - expected = _mapping_value(debug_fields, "bonjour_expected") - instance = _mapping_value(expected, "instance_name") - if not isinstance(instance, str) or not instance: - instance = _expected_bonjour_instance_from_results(results) - host_label = _mapping_value(expected, "host_label") - target_ip = _mapping_value(expected, "target_ip") - parts: list[str] = [] - if isinstance(instance, str) and instance: - parts.append(f"instance_name={instance!r}") - if isinstance(host_label, str) and host_label: - parts.append(f"host_label={host_label!r}") - if isinstance(target_ip, str) and target_ip: - parts.append(f"target_ip={target_ip!r}") - return " ".join(parts), instance if isinstance(instance, str) and instance else None - - -def _zeroconf_debug_summary(zeroconf: object) -> str: - return _debug_summary_fields( - zeroconf, - ( - "ip_version", - "zeroconf_interfaces", - "instance_count", - "resolved_count", - "service_event_count", - "ptr_record_count", - "resolve_attempt_count", - "resolve_success_count", - "resolve_error_count", - ), - ) - - -def _native_dns_sd_context_from_debug( - debug_fields: Mapping[str, object], - *, - expected_instance: str | None, -) -> list[str]: - lines: list[str] = [] - native_error = _mapping_value(debug_fields, "bonjour_native_dns_sd_error") - if isinstance(native_error, str) and native_error: - lines.append(f"INFO native dns-sd diagnostic error: {native_error}") - - native_dns_sd = _mapping_value(debug_fields, "bonjour_native_dns_sd") - summary = _debug_summary_fields(native_dns_sd, ("status", "timeout_sec", "elapsed_sec")) - if summary: - lines.append(f"INFO native dns-sd diagnostics: {summary}") - names = _native_dns_sd_smb_names(native_dns_sd) - if names: - names_text = ", ".join(repr(name) for name in names) - lines.append(f"INFO native dns-sd observed _smb._tcp instances: {names_text}") - else: - lines.append("INFO native dns-sd observed 0 _smb._tcp Add events") - if expected_instance is not None: - matched = "yes" if expected_instance in names else "no" - lines.append(f"INFO native dns-sd observed expected _smb._tcp instance: {matched}") - return lines - - -def _mdns_transport_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: - mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") - if not isinstance(mdns_log, str): - return [] - transport = _last_regex_group(r"mdns transport active: ([^\n]+)", mdns_log) - if not transport: - return [] - return [f"INFO mdns-advertiser transport state: {transport}"] - - -def _mdns_counter_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: - mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") - if not isinstance(mdns_log, str): - return [] - counters = _last_regex_group(r"mdns counters: ([^\n]+)", mdns_log) - if not counters: - return [] - return [f"INFO mdns-advertiser counters: {counters}"] - - -def _last_regex_group(pattern: str, text: str) -> str | None: - matches = list(re.finditer(pattern, text)) - if not matches: - return None - match = matches[-1] - return match.group(1) if match.groups() else match.group(0) - - -def _extract_generated_service_types(mdns_log: str) -> list[str]: - service_types: list[str] = [] - for match in re.finditer(r"serving service: type=([^ ]+)", mdns_log): - service_type = match.group(1) - if service_type not in service_types: - service_types.append(service_type) - return service_types - - -def build_mdns_boot_context(debug_fields: Mapping[str, object]) -> list[str]: - rc_log = _mapping_value(debug_fields, "remote_rc_local_log_tail") - mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") - rc_text = rc_log if isinstance(rc_log, str) else "" - mdns_text = mdns_log if isinstance(mdns_log, str) else "" - combined = f"{rc_text}\n{mdns_text}" - if not combined.strip(): - return [] - - lines: list[str] = [] - capture_failed = any( - marker in combined - for marker in ( - "mDNS snapshot capture exited with failure", - "mDNS snapshot capture ended without status", - "mDNS snapshot capture timed out", - "mDNS snapshot capture did not produce trusted Apple snapshot", - "warning: could not identify local Apple mDNS records", - ) - ) - fallback_generated = ( - "generating AirPort fallback" in combined - or "airport snapshot: wrote" in combined - or "mDNS AirPort snapshot generated" in combined - ) - generated_fallback = "mdns advertiser will fall back to generated records" in combined - - if capture_failed and fallback_generated: - lines.append("INFO trusted Apple mDNS snapshot capture failed; AirPort fallback snapshot was generated") - elif capture_failed and generated_fallback: - lines.append( - "INFO trusted Apple mDNS snapshot capture failed; mdns-advertiser fell back to generated records" - ) - elif capture_failed: - lines.append("INFO trusted Apple mDNS snapshot capture failed") - - snapshot_load = _last_regex_group(r"snapshot load: loaded ([^\n]+)", mdns_text) - if snapshot_load: - lines.append(f"INFO mDNS snapshot load: loaded {snapshot_load}") - - source = _last_regex_group(r"serving summary: source=([^\s]+)", mdns_text) - service_types = _extract_generated_service_types(mdns_text) - if source and service_types: - lines.append( - f"INFO mdns-advertiser source={source}; generated services include {', '.join(service_types)}" - ) - elif source: - lines.append(f"INFO mdns-advertiser source={source}") - - takeover = _last_regex_group(r"mDNS takeover established after ([^\n]+)", mdns_text) - if takeover: - lines.append(f"INFO mDNS takeover established after {takeover}") - - return lines - - -def build_doctor_error(results: list[CheckResult], debug_fields: Mapping[str, object] | None = None) -> str | None: - debug_fields = debug_fields or {} - fail_lines = [f"{result.status} {result.message}" for result in results if result.status == "FAIL"] - warn_lines = [f"{result.status} {result.message}" for result in results if result.status == "WARN"] - info_lines = [ - f"{result.status} {result.message}" - for result in results - if result.status == "INFO" and result.message.startswith("discovered _smb._tcp candidates:") - ] - discovery_lines = build_discovery_context(results, debug_fields) - mdns_boot_lines = build_mdns_boot_context(debug_fields) - lines: list[str] = [] - if fail_lines: - lines.append("Doctor failures:") - lines.extend(fail_lines) - if warn_lines: - if lines: - lines.append("") - lines.append("Doctor warnings:") - lines.extend(warn_lines) - if info_lines: - if lines: - lines.append("") - lines.append("Doctor context:") - lines.extend(info_lines) - if discovery_lines: - if lines: - lines.append("") - lines.append("Discovery context:") - lines.extend(discovery_lines) - if mdns_boot_lines: - if lines: - lines.append("") - lines.append("mDNS boot context:") - lines.extend(mdns_boot_lines) - return "\n".join(lines) if lines else None - - def print_followup_help() -> None: print("") print("Some troubleshooting tips:") diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index ef1edb94..9a6fbc13 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -25,6 +25,7 @@ def __init__( class OperationResult: ok: bool payload: object | None = None + diagnostic_error: object | None = None def jsonable(value: object) -> object: diff --git a/src/timecapsulesmb/services/context.py b/src/timecapsulesmb/services/context.py new file mode 100644 index 00000000..b7e96051 --- /dev/null +++ b/src/timecapsulesmb/services/context.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING + +from timecapsulesmb.telemetry.debug import debug_summary, render_debug_mapping + +if TYPE_CHECKING: + from timecapsulesmb.core.config import AppConfig + from timecapsulesmb.device.probe import ProbedDeviceState + from timecapsulesmb.transport.ssh import SshConnection + + +COMMAND_VALUE_BLACKLIST = { + "TC_PASSWORD", + # Removed naming keys may still exist in old .env files. They are + # intentionally ignored and should not appear as command inputs. + "TC_SAMBA_USER", + "TC_PAYLOAD_DIR_NAME", + "TC_MDNS_HOST_LABEL", + "TC_MDNS_INSTANCE_NAME", + "TC_NETBIOS_NAME", + # These are already first-class operation fields. + "TC_CONFIGURE_ID", + "TC_MDNS_DEVICE_MODEL", + "TC_AIRPORT_SYAP", +} +COMMAND_FIELD_BLACKLIST = { + # These are already first-class operation fields. + "configure_id", + "device_model", + "device_syap", + "device_os_version", + "device_family", + "nbns_enabled", + "reboot_was_attempted", + "device_came_back_after_reboot", +} + + +def _render_connection_debug_lines(connection: SshConnection | None, values: Mapping[str, str] | None) -> list[str]: + host = None + ssh_opts = None + if connection is not None: + host = connection.host + ssh_opts = connection.ssh_opts + elif values is not None: + host = values.get("TC_HOST") or None + ssh_opts = values.get("TC_SSH_OPTS") or None + lines: list[str] = [] + if host: + lines.append(f"host={host}") + if ssh_opts: + lines.append(f"ssh_opts={ssh_opts}") + return lines + + +def render_operation_debug_lines( + *, + operation_name: str, + stage: str | None, + connection: SshConnection | None, + values: Mapping[str, str] | None, + preflight_error: str | None, + finish_fields: Mapping[str, object], + probe_state: ProbedDeviceState | None, + debug_fields: Mapping[str, object], + config: AppConfig | None = None, +) -> list[str]: + debug_values = config.values if config is not None else values + lines = ["Debug context:", f"command={operation_name}"] + if stage: + lines.append(f"stage={stage}") + if config is not None: + lines.append(f"env_path={config.path}") + lines.extend(_render_connection_debug_lines(connection, debug_values)) + if debug_values is not None: + lines.extend(render_debug_mapping(debug_values, blacklist=COMMAND_VALUE_BLACKLIST)) + if preflight_error: + lines.append(f"preflight_error={preflight_error}") + lines.extend(render_debug_mapping(finish_fields, blacklist=COMMAND_FIELD_BLACKLIST)) + if probe_state is not None: + lines.extend(render_debug_mapping(debug_summary(probe_state), blacklist=COMMAND_FIELD_BLACKLIST)) + lines.extend(render_debug_mapping(debug_fields, blacklist=COMMAND_FIELD_BLACKLIST)) + return lines + + +class OperationContext: + """Shared operation diagnostics used by CLI and app/API entrypoints.""" + + def __init__( + self, + operation_name: str, + *, + values: Mapping[str, str] | None = None, + config: AppConfig | None = None, + ) -> None: + self.operation_name = operation_name + self.values = values + self.config = config + self.finish_fields: dict[str, object] = {} + self.error_lines: list[str] = [] + self.preflight_error: str | None = None + self.debug_stage: str | None = None + self.debug_fields: dict[str, object] = {} + self.connection: SshConnection | None = None + self.probe_state: ProbedDeviceState | None = None + + def update_fields(self, **fields: object) -> None: + for key, value in fields.items(): + if value is not None: + self.finish_fields[key] = value + + def set_stage(self, stage: str) -> None: + self.debug_stage = stage + + def add_debug_fields(self, **fields: object) -> None: + for key, value in fields.items(): + if value is not None: + self.debug_fields[key] = debug_summary(value) + + def set_error(self, message: str) -> None: + self.error_lines = [line.rstrip() for line in message.splitlines() if line.strip()] + + def build_error(self) -> str | None: + if not self.error_lines: + return None + return "\n".join([ + *self.error_lines, + "", + *render_operation_debug_lines( + operation_name=self.operation_name, + stage=self.debug_stage, + connection=self.connection, + values=self.values, + preflight_error=self.preflight_error, + finish_fields=self.finish_fields, + probe_state=self.probe_state, + debug_fields=self.debug_fields, + config=self.config, + ), + ]) diff --git a/src/timecapsulesmb/services/doctor.py b/src/timecapsulesmb/services/doctor.py index 992db561..f15c7580 100644 --- a/src/timecapsulesmb/services/doctor.py +++ b/src/timecapsulesmb/services/doctor.py @@ -45,6 +45,10 @@ def _as_sequence(value: object) -> list[object]: return [] +def _bonjour_failure_uses_instance_match(results: list[CheckResult]) -> bool: + return any(result.status == "FAIL" and BONJOUR_INSTANCE_FAILURE_PREFIX in result.message for result in results) + + def _expected_bonjour_instance_from_results(results: list[CheckResult]) -> str | None: for result in results: if result.status != "FAIL" or BONJOUR_INSTANCE_FAILURE_PREFIX not in result.message: @@ -58,18 +62,7 @@ def _expected_bonjour_instance_from_results(results: list[CheckResult]) -> str | 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") +def _native_dns_sd_smb_names(native_dns_sd: object) -> list[str]: names: list[str] = [] for browse in _as_sequence(_mapping_value(native_dns_sd, "browses")): browse_type = str(_mapping_value(browse, "service_type") or "") @@ -89,25 +82,142 @@ def build_discovery_context(results: list[CheckResult], debug_fields: Mapping[st if not _bonjour_failure_uses_instance_match(results): return [] + lines: list[str] = [] + expected_summary, expected_instance = _bonjour_expected_summary(results, debug_fields) + if expected_summary: + lines.append(f"INFO expected Bonjour identity: {expected_summary}") zeroconf = _mapping_value(debug_fields, "bonjour_zeroconf") zeroconf_instance_count = _as_int(_mapping_value(zeroconf, "instance_count")) - if zeroconf_instance_count != 0: - return [] + if zeroconf_instance_count == 0: + lines.append( + "INFO Python zeroconf discovered 0 Bonjour instances during doctor; " + "mDNS advertiser/discovery path needs investigation" + ) + elif zeroconf_instance_count is not None: + lines.append( + f"INFO Python zeroconf discovered {zeroconf_instance_count} Bonjour instance(s), " + "but no matching _smb._tcp instance" + ) + if _authenticated_smb_listing_passed(debug_fields): + lines.append("INFO SMB works over unicast, but Bonjour discovered no matching _smb._tcp records") + zeroconf_summary = _zeroconf_debug_summary(zeroconf) + if zeroconf_summary: + lines.append(f"INFO Python zeroconf diagnostics: {zeroconf_summary}") + lines.extend(_mdns_transport_context_from_debug(debug_fields)) + lines.extend(_mdns_counter_context_from_debug(debug_fields)) + lines.extend(_native_dns_sd_context_from_debug(debug_fields, expected_instance=expected_instance)) + return lines + + +def _authenticated_smb_listing_passed(debug_fields: Mapping[str, object]) -> bool: + for attempt in _as_sequence(_mapping_value(debug_fields, "authenticated_smb_listing_attempts")): + outcome = _mapping_value(attempt, "outcome") + expected_share_found = _mapping_value(attempt, "expected_share_found") + if outcome == "pass" and expected_share_found is True: + return True + return False + + +def _debug_scalar_text(value: object) -> str | None: + if value is None: + return None + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (str, int, float)): + return str(value) + return None - 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}", +def _debug_summary_fields(value: object, keys: tuple[str, ...]) -> str: + parts: list[str] = [] + for key in keys: + text = _debug_scalar_text(_mapping_value(value, key)) + if text is not None: + parts.append(f"{key}={text}") + return " ".join(parts) + + +def _bonjour_expected_summary( + results: list[CheckResult], + debug_fields: Mapping[str, object], +) -> tuple[str, str | None]: + expected = _mapping_value(debug_fields, "bonjour_expected") + instance = _mapping_value(expected, "instance_name") + if not isinstance(instance, str) or not instance: + instance = _expected_bonjour_instance_from_results(results) + host_label = _mapping_value(expected, "host_label") + target_ip = _mapping_value(expected, "target_ip") + parts: list[str] = [] + if isinstance(instance, str) and instance: + parts.append(f"instance_name={instance!r}") + if isinstance(host_label, str) and host_label: + parts.append(f"host_label={host_label!r}") + if isinstance(target_ip, str) and target_ip: + parts.append(f"target_ip={target_ip!r}") + return " ".join(parts), instance if isinstance(instance, str) and instance else None + + +def _zeroconf_debug_summary(zeroconf: object) -> str: + return _debug_summary_fields( + zeroconf, ( - "INFO likely doctor false negative: native macOS mDNS saw the expected service " - "but Python zeroconf did not receive browse events" + "ip_version", + "zeroconf_interfaces", + "instance_count", + "resolved_count", + "service_event_count", + "ptr_record_count", + "resolve_attempt_count", + "resolve_success_count", + "resolve_error_count", ), - ] + ) + + +def _native_dns_sd_context_from_debug( + debug_fields: Mapping[str, object], + *, + expected_instance: str | None, +) -> list[str]: + lines: list[str] = [] + native_error = _mapping_value(debug_fields, "bonjour_native_dns_sd_error") + if isinstance(native_error, str) and native_error: + lines.append(f"INFO native dns-sd diagnostic error: {native_error}") + + native_dns_sd = _mapping_value(debug_fields, "bonjour_native_dns_sd") + summary = _debug_summary_fields(native_dns_sd, ("status", "timeout_sec", "elapsed_sec")) + if summary: + lines.append(f"INFO native dns-sd diagnostics: {summary}") + names = _native_dns_sd_smb_names(native_dns_sd) + if names: + names_text = ", ".join(repr(name) for name in names) + lines.append(f"INFO native dns-sd observed _smb._tcp instances: {names_text}") + else: + lines.append("INFO native dns-sd observed 0 _smb._tcp Add events") + if expected_instance is not None: + matched = "yes" if expected_instance in names else "no" + lines.append(f"INFO native dns-sd observed expected _smb._tcp instance: {matched}") + return lines + + +def _mdns_transport_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: + mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") + if not isinstance(mdns_log, str): + return [] + transport = _last_regex_group(r"mdns transport active: ([^\n]+)", mdns_log) + if not transport: + return [] + return [f"INFO mdns-advertiser transport state: {transport}"] + + +def _mdns_counter_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: + mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") + if not isinstance(mdns_log, str): + return [] + counters = _last_regex_group(r"mdns counters: ([^\n]+)", mdns_log) + if not counters: + return [] + return [f"INFO mdns-advertiser counters: {counters}"] def _last_regex_group(pattern: str, text: str) -> str | None: diff --git a/tests/test_app_api.py b/tests/test_app_api.py index ef75d6fd..16e559ab 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -1410,6 +1410,40 @@ def fake_run_doctor_checks(*_args, **kwargs): self.assertTrue(result["payload"]["fatal"]) self.assertNotIn("pw", json.dumps(collector.events)) + def test_doctor_failure_telemetry_includes_shared_debug_context(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + def fake_run_doctor_checks(*_args, **kwargs): + kwargs["debug_fields"]["bonjour_expected"] = {"instance_name": "Home"} + kwargs["debug_fields"]["bonjour_zeroconf"] = {"instance_count": 0, "ip_version": "V4Only"} + result = CheckResult("FAIL", "no discovered _smb._tcp instance matched expected device instance 'Home'") + kwargs["on_result"](result) + return [result], True + + with mock.patch("timecapsulesmb.app.ops.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) + finished_kwargs = self._telemetry_client.emit.call_args_list[-1].kwargs + telemetry_error = finished_kwargs["error"] + self.assertIn("Doctor failures:", telemetry_error) + self.assertIn("Discovery context:", telemetry_error) + self.assertIn("Debug context:", telemetry_error) + self.assertIn("command=doctor", telemetry_error) + self.assertIn("stage=run_checks", telemetry_error) + self.assertIn("host=root@10.0.0.2", telemetry_error) + self.assertIn("TC_HOST=root@10.0.0.2", telemetry_error) + self.assertIn("bonjour_zeroconf={instance_count:0,ip_version:V4Only}", telemetry_error) + self.assertNotIn("TC_PASSWORD=pw", telemetry_error) + + payload_error = collector.events_of_type("result")[0]["payload"]["error"] + self.assertIn("Doctor failures:", payload_error) + self.assertNotIn("Debug context:", payload_error) + def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> None: collector = CollectingSink() connection = SshConnection("root@10.0.0.2", "pw", "-o foo") diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index e7c1650d..e0d731d5 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -537,7 +537,7 @@ def test_command_context_still_finishes_when_debug_context_rendering_fails(self) client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): with mock.patch.object(client, "_send_payload") as send_mock: - with mock.patch("timecapsulesmb.cli.context.render_command_debug_lines", side_effect=RuntimeError("debug boom")): + with mock.patch("timecapsulesmb.services.context.render_operation_debug_lines", side_effect=RuntimeError("debug boom")): with self.assertRaises(RuntimeError) as raised: with CommandContext(client, "deploy", "deploy_started", "deploy_finished"): raise RuntimeError("upload failed") From 9ec6e5089fd794fe20973cb819fb8b5378d0effd Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 27 May 2026 02:09:16 -0700 Subject: [PATCH 050/129] Add review fixes --- .../TimeCapsuleSMBApp/App/AppCloseGuard.swift | 18 ++--------- .../Policies/DeviceEndpointPolicy.swift | 16 ++++++---- .../Profiles/DeviceProfileEditorStore.swift | 2 +- .../Views/Components/SharedViews.swift | 4 ++- .../Views/Dashboard/MaintenanceTab.swift | 18 ++++++++--- .../AppCloseGuardTests.swift | 22 ++++++++++++++ .../DeviceEndpointPolicyTests.swift | 30 +++++++++++++++++++ .../DeviceProfileEditorStoreTests.swift | 22 ++++++++++++++ 8 files changed, 104 insertions(+), 28 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceEndpointPolicyTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift index c8d5bd04..af98ae45 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift @@ -81,7 +81,6 @@ public final class AppCloseGuard: NSObject { var presenter: AppCloseGuardPresenting = AppCloseGuardAlertPresenter() private var policy = AppCloseGuardPolicy() - private var authorizedWindowCloses: Set = [] public func configure(hasBlockingActivity: @escaping () -> Bool) { policy = AppCloseGuardPolicy(hasBlockingActivity: hasBlockingActivity) @@ -95,12 +94,11 @@ public final class AppCloseGuard: NSObject { AppCloseGuardPrompt.activeOperation, for: .windowClose, modalFor: window - ) { [weak self, weak window] confirmed in + ) { [weak window] confirmed in guard confirmed, let window else { return } - self?.authorizeNextClose(of: window) - window.performClose(nil) + window.close() } return false } @@ -127,14 +125,6 @@ public final class AppCloseGuard: NSObject { objc_setAssociatedObject(window, &windowCloseGuardDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) window.delegate = delegate } - - func consumeAuthorizedClose(of window: NSWindow) -> Bool { - authorizedWindowCloses.remove(ObjectIdentifier(window)) != nil - } - - private func authorizeNextClose(of window: NSWindow) { - authorizedWindowCloses.insert(ObjectIdentifier(window)) - } } @MainActor @@ -160,13 +150,9 @@ private final class GuardedWindowDelegate: NSObject, NSWindowDelegate { } func windowShouldClose(_ sender: NSWindow) -> Bool { - let alreadyConfirmed = AppCloseGuard.shared.consumeAuthorizedClose(of: sender) if let downstreamAllows = downstream?.windowShouldClose?(sender), !downstreamAllows { return false } - if alreadyConfirmed { - return true - } return AppCloseGuard.shared.shouldCloseWindow(sender) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift index 9568c83a..63bdd337 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift @@ -177,12 +177,16 @@ enum DeviceEndpointPolicy { } private static func inetPton(_ family: Int32, _ value: String) -> Bool { - var storage = sockaddr_storage() - return withUnsafeMutablePointer(to: &storage) { pointer in - pointer.withMemoryRebound(to: UInt8.self, capacity: MemoryLayout.size) { raw in - value.withCString { cString in - inet_pton(family, cString, raw) == 1 - } + value.withCString { cString in + switch family { + case AF_INET: + var address = in_addr() + return inet_pton(AF_INET, cString, &address) == 1 + case AF_INET6: + var address = in6_addr() + return inet_pton(AF_INET6, cString, &address) == 1 + default: + return false } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift index fac1fd98..022e2e70 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift @@ -114,7 +114,7 @@ struct DeviceProfileEditorDraft: Equatable { } func hostChanged(from profile: DeviceProfile) -> Bool { - trimmedHost != profile.host.trimmingCharacters(in: .whitespacesAndNewlines) + DeviceEndpointPolicy.normalizedHostKey(trimmedHost) != DeviceEndpointPolicy.normalizedHostKey(profile.host) } func validatedSettings() throws -> DeviceProfileSettings { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift index 5d5e6a6d..d794065c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift @@ -65,7 +65,9 @@ struct DashboardDisclosureSection: View { var body: some View { VStack(alignment: .leading, spacing: 0) { Button { - isExpanded.toggle() + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } } label: { HStack(spacing: 6) { Image(systemName: "chevron.right") diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift index 7c77b5cb..0b3e0ae2 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift @@ -68,8 +68,13 @@ struct MaintenanceTab: View { panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.prompt = L10n.string("maintenance.action.choose") - if panel.runModal() == .OK, let url = panel.url { - store.repairPath = url.path + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + Task { @MainActor in + store.repairPath = url.path + } } } @@ -79,8 +84,13 @@ struct MaintenanceTab: View { panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.prompt = L10n.string("maintenance.action.choose") - if panel.runModal() == .OK, let url = panel.url { - store.firmwareTemplatePath = url.path + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + Task { @MainActor in + store.firmwareTemplatePath = url.path + } } } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift index 3b9577cf..cd4efa0a 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift @@ -35,6 +35,20 @@ final class AppCloseGuardTests: XCTestCase { XCTAssertEqual(presenter.prompts, [.activeOperation, .activeOperation]) } + func testConfirmedWindowCloseClosesWindowDirectly() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { true } + guardController.presenter = presenter + let window = RecordingWindow() + + XCTAssertFalse(guardController.shouldCloseWindow(window)) + + presenter.completions.first?(true) + + XCTAssertEqual(window.closeCount, 1) + } + func testApplicationDelegateRoutesCommandQuitThroughCloseGuard() { let guardController = AppCloseGuard() let presenter = RecordingCloseGuardPresenter() @@ -61,6 +75,14 @@ final class AppCloseGuardTests: XCTestCase { } } +private final class RecordingWindow: NSWindow { + private(set) var closeCount = 0 + + override func close() { + closeCount += 1 + } +} + @MainActor private final class RecordingCloseGuardPresenter: AppCloseGuardPresenting { private(set) var prompts: [AppCloseGuardPrompt] = [] diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceEndpointPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceEndpointPolicyTests.swift new file mode 100644 index 00000000..1e79a39c --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceEndpointPolicyTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class DeviceEndpointPolicyTests: XCTestCase { + func testAddressFamilyParsesIPLiteralForms() { + XCTAssertEqual(DeviceEndpointPolicy.addressFamily(for: "10.0.0.2"), .ipv4) + XCTAssertEqual(DeviceEndpointPolicy.addressFamily(for: "fd00::2"), .ipv6) + XCTAssertEqual(DeviceEndpointPolicy.addressFamily(for: "[fd00::2]"), .ipv6) + XCTAssertEqual(DeviceEndpointPolicy.addressFamily(for: "fe80::1%en0"), .ipv6) + XCTAssertNil(DeviceEndpointPolicy.addressFamily(for: "capsule.local")) + } + + func testHostComponentNormalizesUserURLAndIPv6Wrappers() { + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("root@10.0.0.2"), "10.0.0.2") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("root@[fd00::2]"), "fd00::2") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("smb://admin@capsule.local/share"), "capsule.local") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent(" capsule.local. "), "capsule.local") + } + + func testNormalizedHostKeyTreatsEquivalentTargetsAsEqual() { + XCTAssertEqual( + DeviceEndpointPolicy.normalizedHostKey("root@10.0.0.2"), + DeviceEndpointPolicy.normalizedHostKey("10.0.0.2") + ) + XCTAssertEqual( + DeviceEndpointPolicy.normalizedHostKey("CAPSULE.local."), + DeviceEndpointPolicy.normalizedHostKey("capsule.local") + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift index 8a4ed9cb..949f789a 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift @@ -150,6 +150,28 @@ final class DeviceProfileEditorStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls, []) } + func testEquivalentHostEditDoesNotRunBackendConfigure() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.host = " 10.0.0.2 " + store.draft.displayName = "Media Capsule" + + await store.save(profile: profile) + + let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(store.state, .saved) + XCTAssertEqual(saved.host, "root@10.0.0.2") + XCTAssertEqual(saved.displayName, "Media Capsule") + XCTAssertEqual(fixture.runner.calls, []) + } + func testPasswordOnlySaveUpdatesKeychainAndClearsDraft() async throws { let fixture = try await makeFixture(responses: []) let profile = try await fixture.registry.saveConfiguredDevice( From 263061968fe8ecfd1535ab44ce32e3b24efba303 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 27 May 2026 02:42:15 -0700 Subject: [PATCH 051/129] Implemented the AppOperationContext architecture --- src/timecapsulesmb/app/context.py | 132 +++++++++++++++++ src/timecapsulesmb/app/ops/__init__.py | 4 +- src/timecapsulesmb/app/ops/configure.py | 29 ++-- src/timecapsulesmb/app/ops/deploy.py | 156 +++++++++++---------- src/timecapsulesmb/app/ops/doctor.py | 20 +-- src/timecapsulesmb/app/ops/flash.py | 86 +++++++----- src/timecapsulesmb/app/ops/maintenance.py | 125 ++++++++++------- src/timecapsulesmb/app/ops/reachability.py | 12 +- src/timecapsulesmb/app/ops/readiness.py | 56 ++++---- src/timecapsulesmb/app/service.py | 62 +++++--- tests/test_app_api.py | 73 ++++++++-- 11 files changed, 506 insertions(+), 249 deletions(-) create mode 100644 src/timecapsulesmb/app/context.py diff --git a/src/timecapsulesmb/app/context.py b/src/timecapsulesmb/app/context.py new file mode 100644 index 00000000..8f4ef4ac --- /dev/null +++ b/src/timecapsulesmb/app/context.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.services.context import OperationContext +from timecapsulesmb.telemetry import build_device_os_version + +if TYPE_CHECKING: + from collections.abc import Mapping + + from timecapsulesmb.core.config import AppConfig + from timecapsulesmb.device.probe import ProbedDeviceState + from timecapsulesmb.services.runtime import ManagedTargetState + from timecapsulesmb.transport.ssh import SshConnection + + +class AppOperationContext: + """GUI/API operation adapter around shared diagnostic context and app events.""" + + def __init__(self, operation: str, sink: EventSink) -> None: + self.operation = operation + self.sink = sink + self.diagnostics = OperationContext(operation) + self.result = "failure" + self.error: str | None = None + + @property + def current_stage(self) -> str | None: + return self.diagnostics.debug_stage or self.sink.current_stage(self.operation) + + @property + def current_risk(self) -> str | None: + return self.sink.current_risk(self.operation) + + @property + def values(self) -> Mapping[str, str] | None: + return self.diagnostics.values + + @values.setter + def values(self, value: Mapping[str, str] | None) -> None: + self.diagnostics.values = value + + @property + def config(self) -> AppConfig | None: + return self.diagnostics.config + + @config.setter + def config(self, value: AppConfig | None) -> None: + self.diagnostics.config = value + + @property + def connection(self) -> SshConnection | None: + return self.diagnostics.connection + + @connection.setter + def connection(self, value: SshConnection | None) -> None: + self.diagnostics.connection = value + + @property + def probe_state(self) -> ProbedDeviceState | None: + return self.diagnostics.probe_state + + @probe_state.setter + def probe_state(self, value: ProbedDeviceState | None) -> None: + self.diagnostics.probe_state = value + + @property + def finish_fields(self) -> dict[str, object]: + return self.diagnostics.finish_fields + + def stage(self, stage: str) -> None: + self.diagnostics.set_stage(stage) + self.sink.stage(self.operation, stage) + + def set_stage(self, stage: str) -> None: + self.stage(stage) + + def log(self, message: str, *, level: str = "info") -> None: + self.sink.log(self.operation, message, level=level) + + def check(self, *, status: str, message: str, details: dict[str, object] | None = None) -> None: + self.sink.check(self.operation, status=status, message=message, details=details) + + def emit_result(self, *, ok: bool, payload: object | None = None) -> None: + self.sink.result(self.operation, ok=ok, payload=payload) + + def update_fields(self, **fields: object) -> None: + self.diagnostics.update_fields(**fields) + + def add_debug_fields(self, **fields: object) -> None: + self.diagnostics.add_debug_fields(**fields) + + def set_error(self, message: str) -> None: + self.error = message + self.diagnostics.set_error(message) + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.set_error(message) + + def build_error(self) -> str | None: + return self.diagnostics.build_error() + + def diagnostic_error(self, message: object | None = None) -> str | None: + if message is not None and not self.diagnostics.error_lines: + self.set_error(str(message)) + return self.build_error() + + def apply_managed_target(self, target: ManagedTargetState) -> ManagedTargetState: + self.connection = target.connection + if target.probe_state is not None: + self.apply_probe_state(target.probe_state) + return target + + def apply_probe_state(self, probe_state: ProbedDeviceState) -> None: + self.probe_state = probe_state + probe = probe_state.probe_result + self.update_fields(device_model=probe.airport_model, device_syap=probe.airport_syap) + compatibility = probe_state.compatibility + if compatibility is not None: + self.update_fields( + device_os_version=build_device_os_version( + compatibility.os_name, + compatibility.os_release, + compatibility.arch, + ), + device_family=compatibility.payload_family, + ) diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py index 81525ebf..65740b21 100644 --- a/src/timecapsulesmb/app/ops/__init__.py +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Callable -from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.ops.configure import configure_operation from timecapsulesmb.app.ops.deploy import deploy_operation from timecapsulesmb.app.ops.doctor import doctor_operation @@ -26,7 +26,7 @@ from timecapsulesmb.services.app import OperationResult -OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { +OPERATIONS: dict[str, Callable[[dict[str, object], AppOperationContext], OperationResult]] = { "activate": activate_operation, "capabilities": capabilities_operation, "configure": configure_operation, diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py index 80287f54..f43436dc 100644 --- a/src/timecapsulesmb/app/ops/configure.py +++ b/src/timecapsulesmb/app/ops/configure.py @@ -2,9 +2,9 @@ import uuid +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation from timecapsulesmb.app.contracts import configure_payload -from timecapsulesmb.app.events import EventSink from timecapsulesmb.app.ops.readiness import selected_record_host, selected_record_properties from timecapsulesmb.core.config import ( DEFAULTS, @@ -73,9 +73,8 @@ def require_enable_ssh_confirmation(params: dict[str, object], *, host: str) -> ) -def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "configure" - sink.stage(operation, "load_existing_config") +def configure_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("load_existing_config") app_paths = resolve_app_paths(config_path=config_path(params)) env_path = app_paths.config_path existing = parse_env_file(env_path) @@ -83,6 +82,9 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation ssh_opts = string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) host = configure_ssh_target(string_param(params, "host") or selected_record_host(params) or existing.get("TC_HOST", "")) password = require_string_param(params, "password") + selected_record = params.get("selected_record") + if isinstance(selected_record, dict): + context.add_debug_fields(selected_bonjour_record=selected_record) if not host: raise AppOperationError("missing required parameter: host", code="validation_failed") @@ -117,30 +119,34 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation ) except ValueError as exc: raise AppOperationError(str(exc), code="validation_failed") from exc + context.values = values - sink.stage(operation, "ssh_probe") + context.stage("ssh_probe") connection = SshConnection(host, password, ssh_opts) + context.connection = connection probed_state = probe_connection_state(connection) + context.apply_probe_state(probed_state) 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, "confirm_enable_ssh") + context.stage("confirm_enable_ssh") require_enable_ssh_confirmation(params, host=host) - sink.stage(operation, "acp_enable_ssh") + context.stage("acp_enable_ssh") try: - enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) + enable_ssh(extract_host(host), password, reboot_device=True, log=context.log) 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") + context.stage("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") + context.stage("ssh_probe_after_acp") probed_state = probe_connection_state(connection) + context.apply_probe_state(probed_state) probe = probed_state.probe_result if not probe.ssh_authenticated: @@ -158,8 +164,9 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation observed_model = None if compatibility is None else compatibility.exact_model if observed_syap is None: observed_syap = selected_props.get("syAP") or None + context.update_fields(configure_id=configure_id, device_model=observed_model, device_syap=observed_syap) - sink.stage(operation, "write_env") + context.stage("write_env") env_path.parent.mkdir(parents=True, exist_ok=True) omit_keys = frozenset() if bool_param(params, "persist_password") else frozenset({"TC_PASSWORD"}) EnvFileConfigStore(omit_keys=omit_keys).save(env_path, values) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index a189cac6..7a1b7fb3 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -5,9 +5,9 @@ from pathlib import Path import tempfile +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.contracts import deploy_plan_payload, deploy_result_payload from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation -from timecapsulesmb.app.events import EventSink from timecapsulesmb.core.config import ( DEFAULTS, MANAGED_PAYLOAD_DIR_NAME, @@ -70,6 +70,8 @@ MAST_DISCOVERY_ATTEMPTS, MAST_DISCOVERY_DELAY_SECONDS, build_dry_run_payload_home, + mast_volumes_debug_summary, + payload_candidate_checks_debug_summary, select_payload_home_with_diagnostics_conn, verify_payload_home_conn, wait_for_mast_volumes_conn, @@ -99,6 +101,13 @@ ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 +def _best_effort_debug_summary(render, value: object) -> object | None: + try: + return render(value) + except Exception: + return None + + @dataclass(frozen=True) class DeployConfirmationPresentation: title: str @@ -213,26 +222,27 @@ def require_supported_payload(target: ManagedTargetState, *, allow_unsupported: def load_config_and_target( - operation: str, + context: AppOperationContext, params: dict[str, object], - sink: EventSink, *, profile: str, include_probe: bool, ) -> tuple[AppConfig, ManagedTargetState]: - sink.stage(operation, "load_config") + context.stage("load_config") config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) - sink.stage(operation, "resolve_managed_target") + context.config = config + context.stage("resolve_managed_target") target = resolve_validated_managed_target( config, - command_name=operation, + command_name=context.operation, profile=profile, include_probe=include_probe, ) + context.apply_managed_target(target) return config, target -def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: +def deploy_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: operation = "deploy" nbns_enabled = bool_param(params, "nbns_enabled", True) dry_run = bool_param(params, "dry_run") @@ -248,7 +258,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes ) ata_standby = optional_unsigned_int_override_param(params, "ata_standby") - config, target = load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) + config, target = load_config_and_target(context, params, profile="deploy", include_probe=True) connection = target.connection app_paths = resolve_app_paths(config_path=config_path(params)) internal_share_use_disk_root = bool_param( @@ -262,12 +272,12 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes parse_bool(config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), ) - sink.stage(operation, "validate_artifacts") + context.stage("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") + context.stage("check_compatibility") compatibility = require_supported_payload(target, allow_unsupported=allow_unsupported) payload_family = compatibility.payload_family is_netbsd4 = is_netbsd4_payload_family(payload_family) @@ -277,7 +287,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes connection.remote_has_scp = False startup_mode = startup_mode_for_deploy(no_reboot=no_reboot, is_netbsd4=is_netbsd4) no_wait = effective_no_wait_for_deploy(requested=no_wait, no_reboot=no_reboot) - sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") + context.log(f"Using {payload_family_description(payload_family)} payload.") resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) if not dry_run: confirmation_plan = build_deployment_plan( @@ -333,12 +343,17 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes if dry_run: payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) else: - sink.stage(operation, "read_mast") + context.stage("read_mast") mast_discovery = wait_for_mast_volumes_conn( connection, attempts=MAST_DISCOVERY_ATTEMPTS, delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, ) + context.add_debug_fields( + mast_read_attempts=mast_discovery.attempts, + mast_volume_count=len(mast_discovery.volumes), + mast_candidates=_best_effort_debug_summary(mast_volumes_debug_summary, mast_discovery.volumes), + ) if not mast_discovery.volumes: raise AppOperationError( no_mast_volumes_message( @@ -347,13 +362,19 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes ), code="remote_error", ) - sink.stage(operation, "select_payload_home") + context.stage("select_payload_home") selection = select_payload_home_with_diagnostics_conn( connection, mast_discovery.volumes, MANAGED_PAYLOAD_DIR_NAME, wait_seconds=mount_wait, ) + context.add_debug_fields( + mast_candidate_checks=_best_effort_debug_summary( + payload_candidate_checks_debug_summary, + getattr(selection, "checks", ()), + ) + ) if selection.payload_home is None: raise AppOperationError( no_writable_mast_volumes_message(len(mast_discovery.volumes)), @@ -361,7 +382,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes ) payload_home = selection.payload_home - sink.stage(operation, "build_deployment_plan") + context.stage("build_deployment_plan") plan = build_deployment_plan( connection.host, payload_home, @@ -378,9 +399,9 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes netbsd4=is_netbsd4, )) - sink.stage(operation, "pre_upload_actions") + context.stage("pre_upload_actions") run_remote_actions(connection, plan.pre_upload_actions) - sink.stage(operation, "prepare_deployment_files") + context.stage("prepare_deployment_files") try: flash_config_text = render_flash_runtime_config( config, @@ -416,21 +437,20 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes PACKAGED_BOOT_SOURCE: boot_assets.enter_context(boot_asset_path("boot.sh")), PACKAGED_MANAGER_SOURCE: boot_assets.enter_context(boot_asset_path("manager.sh")), } - sink.stage(operation, "upload_payload") + context.stage("upload_payload") upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) - sink.stage(operation, "post_upload_actions") + context.stage("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...") + verify_payload_upload(context, connection, payload_home, wait_seconds=mount_wait) + context.stage("flush_payload_upload") + context.log("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) + verify_payload_upload(context, connection, payload_home, wait_seconds=mount_wait, post_sync=True) if startup_mode == DEPLOY_STARTUP_ACTIVATE_NOW: activate_deployed_runtime( - operation, - sink, + context, connection, plan.activation_actions, skip_if_ready=False, @@ -455,8 +475,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes if no_wait: request_reboot( - operation, - sink, + context, connection, strategy="ssh_shutdown_then_reboot", raise_on_request_error=True, @@ -470,8 +489,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes )) request_reboot_and_wait( - operation, - sink, + context, connection, strategy="ssh_shutdown_then_reboot", reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, @@ -479,8 +497,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: activate_deployed_runtime( - operation, - sink, + context, connection, plan.activation_actions, skip_if_ready=True, @@ -503,7 +520,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes payload_family=payload_family, )) - verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) + verify_runtime(context, connection, stage="verify_runtime_reboot", timeout_seconds=240) return OperationResult(True, deploy_result_payload( payload_dir=plan.payload_dir, rebooted=True, @@ -515,8 +532,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes def activate_deployed_runtime( - operation: str, - sink: EventSink, + context: AppOperationContext, connection: SshConnection, activation_actions, *, @@ -531,17 +547,16 @@ def activate_deployed_runtime( failure_message: str = "Managed runtime activation failed.", ) -> None: if skip_if_ready: - sink.stage(operation, "probe_runtime") + context.stage("probe_runtime") preflight = probe_runtime_activation_state_conn(connection, timeout_seconds=probe_timeout_seconds) - sink.log(operation, preflight.detail) + context.log(preflight.detail) if preflight.state == RUNTIME_ACTIVATION_STATE_READY: - sink.log(operation, already_active_message) + context.log(already_active_message) return if preflight.state == RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING: - sink.log(operation, startup_in_progress_message) + context.log(startup_in_progress_message) verify_runtime( - operation, - sink, + context, connection, stage=verification_stage, timeout_seconds=verification_timeout_seconds, @@ -549,12 +564,11 @@ def activate_deployed_runtime( ) return - sink.stage(operation, activation_stage) - sink.log(operation, activation_message) + context.stage(activation_stage) + context.log(activation_message) run_remote_actions(connection, activation_actions) verify_runtime( - operation, - sink, + context, connection, stage=verification_stage, timeout_seconds=verification_timeout_seconds, @@ -563,37 +577,38 @@ def activate_deployed_runtime( def verify_payload_upload( - operation: str, - sink: EventSink, + context: AppOperationContext, 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") + context.stage("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) + context.log(verification.detail) + context.add_debug_fields( + **{"payload_post_sync_verification" if post_sync else "payload_upload_verification": verification.detail} + ) if not verification.ok: raise AppOperationError(payload_verification_error(payload_home, verification), code="remote_error") def verify_runtime( - operation: str, - sink: EventSink, + context: AppOperationContext, connection: SshConnection, *, stage: str, timeout_seconds: int, failure_message: str = "Managed runtime did not become ready.", ) -> None: - sink.stage(operation, stage) + context.stage(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) + context.log(line) if not managed_runtime_ready(verification): raise AppOperationError( f"{failure_message.rstrip()} {verification.detail.strip()}".strip(), @@ -602,8 +617,7 @@ def verify_runtime( def request_reboot_and_wait( - operation: str, - sink: EventSink, + context: AppOperationContext, connection: SshConnection, *, strategy: str, @@ -611,52 +625,48 @@ def request_reboot_and_wait( down_timeout_seconds: int = 60, up_timeout_seconds: int = 240, ) -> None: - request_reboot(operation, sink, connection, strategy=strategy) + request_reboot(context, connection, strategy=strategy) - sink.stage(operation, "wait_for_reboot_down") - sink.log(operation, "Waiting for the device to go down...") + context.stage("wait_for_reboot_down") + context.log("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...") + context.stage("wait_for_reboot_up") + context.log("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.") + context.log("Device is back online.") def request_reboot( - operation: str, - sink: EventSink, + context: AppOperationContext, connection: SshConnection, *, strategy: str, raise_on_request_error: bool = False, ) -> None: - sink.stage(operation, "reboot") + context.stage("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.") + context.log("ACP reboot requested.") except ACPError as exc: - sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + context.log(f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") request_ssh_reboot( - operation, - sink, + context, connection, raise_on_request_error=raise_on_request_error, ) else: request_ssh_reboot( - operation, - sink, + context, connection, raise_on_request_error=raise_on_request_error, ) def request_ssh_reboot( - operation: str, - sink: EventSink, + context: AppOperationContext, connection: SshConnection, *, raise_on_request_error: bool = False, @@ -666,11 +676,11 @@ def request_ssh_reboot( except SshCommandTimeout as exc: if raise_on_request_error: raise AppOperationError(f"SSH reboot request timed out: {exc}", code="remote_error") from exc - sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") + context.log(f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") return except SshError as exc: if raise_on_request_error: raise AppOperationError(f"SSH reboot request failed: {exc}", code="remote_error") from exc - sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") + context.log(f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") return - sink.log(operation, "SSH reboot requested.") + context.log("SSH reboot requested.") diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py index f7bbf603..9185e17a 100644 --- a/src/timecapsulesmb/app/ops/doctor.py +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -1,26 +1,18 @@ from __future__ import annotations +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.contracts import doctor_payload -from timecapsulesmb.app.events import EventSink from timecapsulesmb.checks.doctor import run_doctor_checks from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.core.paths import resolve_app_paths from timecapsulesmb.services.app import OperationResult, bool_param, config_path -from timecapsulesmb.services.context import OperationContext from timecapsulesmb.services.credentials import overlay_request_credentials from timecapsulesmb.services.doctor import build_doctor_error, doctor_status_counts from timecapsulesmb.services.runtime import load_env_config, resolve_env_connection -def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "doctor" - context = OperationContext(operation) - - def stage(name: str) -> None: - context.set_stage(name) - sink.stage(operation, name) - - stage("load_config") +def doctor_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("load_config") config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) context.config = config app_paths = resolve_app_paths(config_path=config_path(params)) @@ -30,15 +22,15 @@ def stage(name: str) -> None: context.update_fields(skip_ssh=skip_ssh, skip_bonjour=skip_bonjour, skip_smb=skip_smb) connection = None if not skip_ssh and config.has_value("TC_HOST"): - stage("resolve_connection") + context.stage("resolve_connection") connection = resolve_env_connection(config, allow_empty_password=True) context.connection = connection debug_fields: dict[str, object] = {} def on_result(result: CheckResult) -> None: - sink.check(operation, status=result.status, message=result.message, details=result.details) + context.check(status=result.status, message=result.message, details=result.details) - stage("run_checks") + context.stage("run_checks") results, fatal = run_doctor_checks( config, repo_root=app_paths.distribution_root, diff --git a/src/timecapsulesmb/app/ops/flash.py b/src/timecapsulesmb/app/ops/flash.py index d12da13b..38789947 100644 --- a/src/timecapsulesmb/app/ops/flash.py +++ b/src/timecapsulesmb/app/ops/flash.py @@ -2,9 +2,9 @@ from pathlib import Path +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation from timecapsulesmb.app.contracts import flash_backup_payload, flash_plan_payload, flash_write_payload -from timecapsulesmb.app.events import EventSink from timecapsulesmb.core.config import AppConfig from timecapsulesmb.device.compat import is_netbsd4_payload_family, payload_family_description from timecapsulesmb.device.errors import DeviceError @@ -40,16 +40,16 @@ PLAN_OPERATIONS = {"patch", "restore", "check_apple", "download_only"} -def flash_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "flash" +def flash_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: action = string_param(params, "action", "backup").strip() or "backup" if action not in FLASH_ACTIONS: raise AppOperationError(f"unsupported flash action: {action}", code="validation_failed") + context.update_fields(flash_action=action) if action == "backup": - return _backup_operation(params, sink) + return _backup_operation(params, context) if action == "plan": - return _plan_operation(params, sink) - return _write_operation(params, sink) + return _plan_operation(params, context) + return _write_operation(params, context) def _optional_path_param(params: dict[str, object], name: str) -> Path | None: @@ -82,44 +82,49 @@ def _write_operation_param(params: dict[str, object]) -> str: return plan_operation -def _load_flash_config(params: dict[str, object], sink: EventSink) -> AppConfig: - sink.stage("flash", "load_config") - return overlay_request_credentials(load_env_config(env_path=config_path(params)), params) +def _load_flash_config(params: dict[str, object], context: AppOperationContext) -> AppConfig: + context.stage("load_config") + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + context.config = config + return config -def _resolve_flash_target(config: AppConfig, sink: EventSink) -> FlashTarget: - sink.stage("flash", "resolve_connection") +def _resolve_flash_target(config: AppConfig, context: AppOperationContext) -> FlashTarget: + context.stage("resolve_connection") target = resolve_validated_managed_target( config, command_name="flash", profile="flash", include_probe=False, ) - sink.stage("flash", "check_compatibility") + context.apply_managed_target(target) + context.stage("check_compatibility") try: compatibility = require_connection_compatibility(target.connection) except DeviceError as exc: raise AppOperationError(str(exc), code="unsupported_device") from exc + context.update_fields(device_family=compatibility.payload_family) if not is_netbsd4_payload_family(compatibility.payload_family): raise AppOperationError( "flash is only supported for NetBSD4 AirPort storage devices.", code="unsupported_device", ) - sink.log("flash", f"Using {payload_family_description(compatibility.payload_family)} payload family for flash work.") + context.log(f"Using {payload_family_description(compatibility.payload_family)} payload family for flash work.") return flash_target_from_connection(target.connection, compatibility) -def _backup_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - config = _load_flash_config(params, sink) - target = _resolve_flash_target(config, sink) +def _backup_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + config = _load_flash_config(params, context) + target = _resolve_flash_target(config, context) backup_dir = _optional_path_param(params, "backup_dir") + context.update_fields(backup_dir=str(backup_dir) if backup_dir is not None else None) try: bundle = backup_flash( target=target, backup_dir=backup_dir, operation="read_only", - log=lambda message: sink.log("flash", message), - stage=lambda stage: sink.stage("flash", stage), + log=context.log, + stage=context.stage, ) except FlashAnalysisError as exc: raise AppOperationError(str(exc), code="validation_failed") from exc @@ -128,15 +133,22 @@ def _backup_operation(params: dict[str, object], sink: EventSink) -> OperationRe return OperationResult(True, flash_backup_payload(bundle.manifest)) -def _plan_operation(params: dict[str, object], sink: EventSink) -> OperationResult: +def _plan_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: plan_operation = _plan_operation_param(params) force = bool_param(params, "force") backup_dir = required_path_param(params, "backup_dir") firmware_template = _firmware_template_param(params) firmware_version = _firmware_version_param(params) + context.update_fields( + flash_mode=plan_operation, + force=force, + backup_dir=str(backup_dir), + firmware_template=str(firmware_template) if firmware_template is not None else None, + firmware_version=firmware_version, + ) try: - sink.stage("flash", "inspect_backup") - sink.stage("flash", "plan_flash") + context.stage("inspect_backup") + context.stage("plan_flash") bundle, _plan = plan_flash_from_backup( backup_dir=backup_dir, operation=plan_operation, @@ -159,16 +171,23 @@ def _confirmation_message(target: FlashTarget, mode: str, bank: str | None) -> s return f"Restore Apple stock firmware to the active{bank_text} bank on {target.acp_host}?" -def _write_operation(params: dict[str, object], sink: EventSink) -> OperationResult: +def _write_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: plan_operation = _write_operation_param(params) force = bool_param(params, "force") backup_dir = required_path_param(params, "backup_dir") firmware_template = _firmware_template_param(params) firmware_version = _firmware_version_param(params) + context.update_fields( + flash_mode=plan_operation, + force=force, + backup_dir=str(backup_dir), + firmware_template=str(firmware_template) if firmware_template is not None else None, + firmware_version=firmware_version, + ) try: - sink.stage("flash", "inspect_backup") - sink.stage("flash", "plan_flash") + context.stage("inspect_backup") + context.stage("plan_flash") bundle, plan = plan_flash_from_backup( backup_dir=backup_dir, operation=plan_operation, @@ -190,10 +209,11 @@ def _write_operation(params: dict[str, object], sink: EventSink) -> OperationRes ) return OperationResult(True, flash_write_payload(bundle.manifest)) - config = _load_flash_config(params, sink) - target = _resolve_flash_target(config, sink) + config = _load_flash_config(params, context) + target = _resolve_flash_target(config, context) bank = None if plan.target_bank is None else plan.target_bank.name - sink.stage("flash", "confirm_write") + context.update_fields(target_bank=bank) + context.stage("confirm_write") require_confirmation( params, build_confirmation( @@ -223,21 +243,21 @@ def _write_operation(params: dict[str, object], sink: EventSink) -> OperationRes ) try: - sink.stage("flash", "pre_write_validation") + context.stage("pre_write_validation") validate_live_target_matches_backup( connection=target.connection, plan=plan, - log=lambda message: sink.log("flash", message), + log=context.log, ) - sink.stage("flash", "write_primary_bank" if plan_operation == "patch" else "write_active_bank") - sink.log("flash", "Sending ACP flash command...") + context.stage("write_primary_bank" if plan_operation == "patch" else "write_active_bank") + context.log("Sending ACP flash command...") write_flash_plan( target=target, bundle=bundle, plan=plan, - log=lambda message: sink.log("flash", message), + log=context.log, ) - sink.stage("flash", "post_write_validation") + context.stage("post_write_validation") except FlashAnalysisError as exc: raise AppOperationError(str(exc), code="operation_failed") from exc except TransportError as exc: diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index 7bf7ec98..e1b9bb84 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -5,6 +5,7 @@ import sys from contextlib import redirect_stderr, redirect_stdout +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.contracts import ( activation_plan_payload, activation_result_payload, @@ -16,7 +17,6 @@ uninstall_result_payload, ) from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation -from timecapsulesmb.app.events import EventSink from timecapsulesmb.app.ops.deploy import ( request_reboot, request_reboot_and_wait, @@ -30,7 +30,7 @@ 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_runtime_activation_plan, build_uninstall_plan, ) from timecapsulesmb.deploy.verify import render_post_uninstall_verification, verify_post_uninstall @@ -38,6 +38,7 @@ 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, + mast_volumes_debug_summary, mounted_mast_volumes_conn, read_mast_volumes_conn, ) @@ -59,7 +60,6 @@ FSCK_REBOOT_NO_DOWN_MESSAGE, UNINSTALL_REBOOT_NO_DOWN_MESSAGE, LineLogCapture, - RepairExecutionContext, build_remote_fsck_script, format_fsck_plan, format_fsck_targets, @@ -78,16 +78,24 @@ from timecapsulesmb.transport.ssh import SshConnection, run_ssh -def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: +def _best_effort_mast_debug_summary(volumes: object) -> object | None: + try: + return mast_volumes_debug_summary(volumes) + except Exception: + return None + + +def activate_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: operation = "activate" dry_run = bool_param(params, "dry_run") - sink.stage(operation, "build_activation_plan") - plan = build_netbsd4_activation_plan() + context.stage("build_activation_plan") + plan = build_runtime_activation_plan() if dry_run: return OperationResult(True, activation_plan_payload(activation_plan_to_jsonable(plan))) - sink.stage(operation, "load_config") + context.stage("load_config") config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + context.config = config confirmation_connection = resolve_env_connection(config, allow_empty_password=True) require_confirmation( params, @@ -109,13 +117,14 @@ def activate_operation(params: dict[str, object], sink: EventSink) -> OperationR legacy_names=("confirm_netbsd4_activation",), ) - sink.stage(operation, "resolve_managed_target") + context.stage("resolve_managed_target") target = resolve_validated_managed_target( config, command_name=operation, profile="activate", include_probe=True, ) + context.apply_managed_target(target) compatibility = require_supported_payload(target, allow_unsupported=False) if not is_netbsd4_payload_family(compatibility.payload_family): raise AppOperationError( @@ -123,29 +132,31 @@ def activate_operation(params: dict[str, object], sink: EventSink) -> OperationR code="unsupported_device", ) connection = target.connection - sink.stage(operation, "probe_runtime") + context.stage("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") + context.stage("run_activation") run_remote_actions(connection, plan.actions) - verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + verify_runtime(context, 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: +def uninstall_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: operation = "uninstall" dry_run = bool_param(params, "dry_run") no_reboot = bool_param(params, "no_reboot") no_wait = bool_param(params, "no_wait") mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) - sink.stage(operation, "load_config") + context.stage("load_config") config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) - sink.stage(operation, "resolve_connection") + context.config = config + context.stage("resolve_connection") connection = resolve_env_connection(config, allow_empty_password=True) + context.connection = connection if not dry_run: presentation_id = "uninstall.no_reboot" if no_reboot else "uninstall.reboot" presentation_values = { @@ -181,21 +192,29 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation 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") + context.stage("read_mast") mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_mast_volumes") + context.add_debug_fields( + mast_volume_count=len(mast_volumes), + mast_candidates=_best_effort_mast_debug_summary(mast_volumes), + ) + context.stage("mount_mast_volumes") mounted_volumes = mounted_mast_volumes_conn( connection, mast_volumes, wait_seconds=mount_wait, ) + context.add_debug_fields( + mast_mounted_volume_count=len(mounted_volumes), + mast_mounted_candidates=_best_effort_mast_debug_summary(mounted_volumes), + ) 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") + context.stage("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") + context.stage("uninstall_payload") remote_uninstall_payload(connection, plan) if no_reboot: return OperationResult(True, uninstall_result_payload( @@ -206,8 +225,7 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation )) if no_wait: request_reboot( - operation, - sink, + context, connection, strategy="acp_then_ssh", raise_on_request_error=True, @@ -219,16 +237,15 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation waited=False, )) request_reboot_and_wait( - operation, - sink, + context, connection, strategy="acp_then_ssh", reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, ) - sink.stage(operation, "verify_post_uninstall") + context.stage("verify_post_uninstall") verification = verify_post_uninstall(connection, plan) for line in render_post_uninstall_verification(verification): - sink.log(operation, line) + context.log(line) if not verification: raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") return OperationResult(True, uninstall_result_payload( @@ -239,7 +256,7 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation )) -def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: +def fsck_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: operation = "fsck" dry_run = bool_param(params, "dry_run") list_volumes = bool_param(params, "list_volumes") @@ -280,27 +297,37 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul ), legacy_names=("confirm_fsck",), ) - sink.stage(operation, "load_config") + context.stage("load_config") config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) - sink.stage(operation, "resolve_connection") + context.config = config + context.stage("resolve_connection") connection = resolve_env_connection(config, allow_empty_password=True) - sink.stage(operation, "read_mast") + context.connection = connection + context.stage("read_mast") mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_hfs_volumes") + context.add_debug_fields( + mast_volume_count=len(mast_volumes), + mast_candidates=_best_effort_mast_debug_summary(mast_volumes), + ) + context.stage("mount_hfs_volumes") mounted_volumes = mounted_mast_volumes_conn( connection, mast_volumes, wait_seconds=mount_wait, ) + context.add_debug_fields( + mast_mounted_volume_count=len(mounted_volumes), + mast_mounted_candidates=_best_effort_mast_debug_summary(mounted_volumes), + ) 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)) + context.stage("list_fsck_volumes") + context.log(format_fsck_targets(targets)) return OperationResult(True, fsck_volume_list_payload({ "targets": [fsck_target_to_jsonable(target) for target in targets], })) - sink.stage(operation, "select_fsck_volume") + context.stage("select_fsck_volume") try: target = select_fsck_target( targets, @@ -309,15 +336,16 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul ) except RuntimeError as exc: raise AppOperationError(str(exc), code="validation_failed") from exc + context.update_fields(fsck_device=target.device, fsck_mountpoint=target.mountpoint) if dry_run: - sink.log(operation, format_fsck_plan(target, reboot=not no_reboot, wait=not no_wait)) + context.log(format_fsck_plan(target, reboot=not no_reboot, wait=not no_wait)) return OperationResult(True, fsck_plan_payload(fsck_plan_to_jsonable( target, reboot=not no_reboot, wait=not no_wait, ))) - sink.stage(operation, "run_fsck") + context.stage("run_fsck") script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) proc = run_ssh( connection, @@ -327,7 +355,10 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul ) if proc.stdout: for line in proc.stdout.splitlines(): - sink.log(operation, line) + context.log(line) + context.update_fields(returncode=proc.returncode) + if proc.returncode != 0: + context.set_error(f"fsck exited with status {proc.returncode}") if no_reboot: return OperationResult(proc.returncode == 0, fsck_result_payload( device=target.device, @@ -346,8 +377,7 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul verified=False, )) observe_reboot_cycle( - operation, - sink, + context, connection, reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, down_timeout_seconds=90, @@ -363,25 +393,24 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul def observe_reboot_cycle( - operation: str, - sink: EventSink, + context: AppOperationContext, connection: SshConnection, *, reboot_no_down_message: str, down_timeout_seconds: int, up_timeout_seconds: int, ) -> None: - sink.stage(operation, "wait_for_reboot_down") + context.stage("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") + context.stage("wait_for_reboot_up") if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): raise AppOperationError(DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") -def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: +def repair_xattrs_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: operation = "repair-xattrs" - sink.stage(operation, "validate_params") + context.stage("validate_params") dry_run = bool_param(params, "dry_run") path = required_path_param(params, "path") recursive = bool_param(params, "recursive", True) @@ -407,13 +436,14 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera ), legacy_names=("confirm_repair",), ) - sink.stage(operation, "platform_check") + context.stage("platform_check") if sys.platform != "darwin": raise AppOperationError( "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", code="validation_failed", ) config = load_optional_env_config(env_path=config_path(params)) + context.config = config args = argparse.Namespace( path=path, dry_run=dry_run, @@ -425,16 +455,15 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera fix_permissions=fix_permissions, verbose=verbose, ) - context = RepairExecutionContext(lambda stage: sink.stage(operation, stage)) - stdout_capture = LineLogCapture(lambda message: sink.log(operation, message, level="info")) - stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) + stdout_capture = LineLogCapture(lambda message: context.log(message, level="info")) + stderr_capture = LineLogCapture(lambda message: context.log(message, level="warning")) try: with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): result = repair_xattrs_service.run_repair_structured( args, context, config, - emit_log=lambda message: sink.log(operation, message), + emit_log=context.log, ) except SystemExit as exc: message = system_exit_message(exc) or "repair-xattrs failed" diff --git a/src/timecapsulesmb/app/ops/reachability.py b/src/timecapsulesmb/app/ops/reachability.py index e3e87d18..e9d07b11 100644 --- a/src/timecapsulesmb/app/ops/reachability.py +++ b/src/timecapsulesmb/app/ops/reachability.py @@ -1,24 +1,24 @@ from __future__ import annotations +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.contracts import reachability_payload -from timecapsulesmb.app.events import EventSink from timecapsulesmb.services.app import OperationResult, config_path from timecapsulesmb.services.credentials import overlay_request_credentials, request_password from timecapsulesmb.services.reachability import run_reachability from timecapsulesmb.services.runtime import load_optional_env_config -def reachability_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "reachability" - sink.stage(operation, "load_config") +def reachability_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("load_config") config = load_optional_env_config(env_path=config_path(params)) config = overlay_request_credentials(config, params) + context.config = config result = run_reachability( config, params, password=request_password(params), - stage=lambda stage: sink.stage(operation, stage), + stage=context.stage, ) for check in result.checks: details = {} @@ -26,5 +26,5 @@ def reachability_operation(params: dict[str, object], sink: EventSink) -> Operat details["host"] = check.host if check.detail is not None: details["detail"] = check.detail - sink.check(operation, status=check.status, message=check.message, details=details) + context.check(status=check.status, message=check.message, details=details) return OperationResult(True, reachability_payload(result)) diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py index 8d874105..543226a0 100644 --- a/src/timecapsulesmb/app/ops/readiness.py +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -3,6 +3,7 @@ import hashlib from urllib.parse import urlparse +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.contracts import ( capabilities_payload, discover_payload, @@ -11,7 +12,6 @@ telemetry_identity_payload, version_check_payload, ) -from timecapsulesmb.app.events import EventSink from timecapsulesmb.cli.version_check import VERSION_CHECK_URL, check_client_version from timecapsulesmb.core.paths import artifact_manifest_resource, resolve_app_paths from timecapsulesmb.core.release import CLI_VERSION, CLI_VERSION_CODE @@ -19,7 +19,7 @@ DEFAULT_BROWSE_TIMEOUT_SEC, BonjourDiscoverySnapshot, BonjourResolvedService, - discover_snapshot, + discover_snapshot_merged_detailed, discovered_record_root_host, discovery_record_to_jsonable, service_instance_to_jsonable, @@ -78,19 +78,17 @@ def snapshot_payload(snapshot: BonjourDiscoverySnapshot) -> dict[str, object]: } -def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "discover" +def discover_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: timeout = float_param(params, "timeout", DEFAULT_BROWSE_TIMEOUT_SEC) - sink.stage(operation, "bonjour_discovery") - snapshot = discover_snapshot(timeout=timeout) + context.stage("bonjour_discovery") + snapshot, _diagnostics = discover_snapshot_merged_detailed(timeout=timeout) return OperationResult(True, discover_payload(snapshot_payload(snapshot))) -def capabilities_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "capabilities" - sink.stage(operation, "resolve_paths") +def capabilities_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("resolve_paths") app_paths = resolve_app_paths(config_path=config_path(params)) - sink.stage(operation, "summarize_capabilities") + context.stage("summarize_capabilities") try: manifest_hash = hashlib.sha256(artifact_manifest_resource().read_bytes()).hexdigest() except OSError: @@ -121,24 +119,21 @@ def capabilities_operation(params: dict[str, object], sink: EventSink) -> Operat )) -def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "paths" - sink.stage(operation, "resolve_paths") +def paths_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("resolve_paths") app_paths = resolve_app_paths(config_path=config_path(params)) - sink.stage(operation, "summarize_artifacts") + context.stage("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") +def validate_install_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("resolve_paths") app_paths = resolve_app_paths(config_path=config_path(params)) - sink.stage(operation, "validate_install") + context.stage("validate_install") checks = validate_install(app_paths) ok = install_ok(checks) for check in checks: - sink.check( - operation, + context.check( status="PASS" if check.ok else "FAIL", message=check.message, details=check.details, @@ -146,11 +141,10 @@ def validate_install_operation(params: dict[str, object], sink: EventSink) -> Op return OperationResult(ok, install_validation_payload(ok=ok, checks=install_checks_to_jsonable(checks))) -def telemetry_identity_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "telemetry-identity" - sink.stage(operation, "resolve_paths") +def telemetry_identity_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("resolve_paths") app_paths = resolve_app_paths(config_path=config_path(params)) - sink.stage(operation, "read_bootstrap") + context.stage("read_bootstrap") identity = load_install_identity(app_paths.bootstrap_path) return OperationResult( True, @@ -158,14 +152,13 @@ def telemetry_identity_operation(params: dict[str, object], sink: EventSink) -> ) -def set_telemetry_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "set-telemetry" +def set_telemetry_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: if "enabled" not in params: raise AppOperationError("missing required parameter: enabled", code="validation_failed") enabled = bool_param(params, "enabled") - sink.stage(operation, "resolve_paths") + context.stage("resolve_paths") app_paths = resolve_app_paths(config_path=config_path(params)) - sink.stage(operation, "write_bootstrap") + context.stage("write_bootstrap") identity = set_telemetry_enabled(enabled, app_paths.bootstrap_path) return OperationResult( True, @@ -173,14 +166,13 @@ def set_telemetry_operation(params: dict[str, object], sink: EventSink) -> Opera ) -def version_check_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "version-check" +def version_check_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: url = string_param(params, "url", VERSION_CHECK_URL).strip() or VERSION_CHECK_URL parsed_url = urlparse(url) if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: raise AppOperationError("url must be an HTTP/HTTPS URL", code="validation_failed") - sink.stage(operation, "resolve_paths") + context.stage("resolve_paths") app_paths = resolve_app_paths(config_path=config_path(params)) - sink.stage(operation, "check_version") + context.stage("check_version") result = check_client_version(url=url, cache_path=app_paths.version_check_cache_path) return OperationResult(True, version_check_payload(result)) diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py index a0fe8c8b..74f7714b 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -3,6 +3,7 @@ import traceback from collections.abc import Callable +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.events import EventSink, redact from timecapsulesmb.app.ops import OPERATIONS, TELEMETRY_OPERATIONS from timecapsulesmb.app.confirmations import AppConfirmationRequired @@ -41,7 +42,7 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: operation = api_request.operation params = api_request.params - handler: Callable[[dict[str, object], EventSink], OperationResult] | None = OPERATIONS.get(operation) + handler: Callable[[dict[str, object], AppOperationContext], OperationResult] | None = OPERATIONS.get(operation) if handler is None: sink.error( operation, @@ -54,27 +55,27 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: telemetry_session = _api_telemetry_session(operation, params) if telemetry_session is not None: telemetry_session.start() + context = AppOperationContext(operation, sink) try: - result = handler(params, sink) + result = handler(params, context) except AppConfirmationRequired as exc: sink.error( operation, str(exc), code=exc.code, details=exc.confirmation.to_jsonable(), - recovery=recovery_for(operation, exc.code, stage=sink.current_stage(operation)), + recovery=recovery_for(operation, exc.code, stage=context.current_stage), ) _finish_api_telemetry( telemetry_session, - sink, - operation, + context, result="confirmation_required", details=confirmation_details(exc.confirmation), risk=exc.confirmation.risk, ) return 1 except AppOperationError as exc: - recovery = exc.recovery or recovery_for(operation, exc.code, stage=sink.current_stage(operation)) + recovery = exc.recovery or recovery_for(operation, exc.code, stage=context.current_stage) sink.error( operation, str(exc), @@ -82,25 +83,40 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: debug=redact(exc.debug) if exc.debug is not None else None, recovery=recovery, ) - _finish_api_telemetry(telemetry_session, sink, operation, result="failure", error=str(exc)) + _finish_api_telemetry( + telemetry_session, + context, + result="failure", + error=context.diagnostic_error(str(exc)) or str(exc), + ) return 1 except ConfigError as exc: sink.error( operation, str(exc), code="config_error", - recovery=recovery_for(operation, "config_error", stage=sink.current_stage(operation)), + recovery=recovery_for(operation, "config_error", stage=context.current_stage), + ) + _finish_api_telemetry( + telemetry_session, + context, + result="failure", + error=context.diagnostic_error(str(exc)) or str(exc), ) - _finish_api_telemetry(telemetry_session, sink, operation, result="failure", error=str(exc)) return 1 except TransportError as exc: sink.error( operation, str(exc), code="remote_error", - recovery=recovery_for(operation, "remote_error", stage=sink.current_stage(operation)), + recovery=recovery_for(operation, "remote_error", stage=context.current_stage), + ) + _finish_api_telemetry( + telemetry_session, + context, + result="failure", + error=context.diagnostic_error(str(exc)) or str(exc), ) - _finish_api_telemetry(telemetry_session, sink, operation, result="failure", error=str(exc)) return 1 except (SystemExit, KeyboardInterrupt): raise @@ -111,17 +127,22 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: message, 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)), + recovery=recovery_for(operation, "operation_failed", stage=context.current_stage), + ) + _finish_api_telemetry( + telemetry_session, + context, + result="failure", + error=context.diagnostic_error(message) or message, ) - _finish_api_telemetry(telemetry_session, sink, operation, result="failure", error=message) return 1 - sink.result(operation, ok=result.ok, payload=result.payload) + context.emit_result(ok=result.ok, payload=result.payload) + payload_error = _payload_error(result.payload) if not result.ok else None _finish_api_telemetry( telemetry_session, - sink, - operation, + context, result="success" if result.ok else "failure", - error=(result.diagnostic_error or _payload_error(result.payload)) if not result.ok else None, + error=(result.diagnostic_error or context.diagnostic_error(payload_error) or payload_error) if not result.ok else None, details=telemetry_details_from_payload(operation, params, result.payload), ) return 0 if result.ok else 1 @@ -149,8 +170,7 @@ def _api_telemetry_session(operation: str, params: dict[str, object]) -> Operati def _finish_api_telemetry( session: OperationTelemetrySession | None, - sink: EventSink, - operation: str, + context: AppOperationContext, *, result: str, error: object | None = None, @@ -162,8 +182,8 @@ def _finish_api_telemetry( session.finish( result=result, error=error, - stage=sink.current_stage(operation), - risk=risk or sink.current_risk(operation), + stage=context.current_stage, + risk=risk or context.current_risk, details=details, ) diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 16e559ab..7d22cf7d 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -20,6 +20,7 @@ sys.path.insert(0, str(SRC_ROOT)) from timecapsulesmb.app.events import AppEvent, EventSink +from timecapsulesmb.app.context import AppOperationContext from timecapsulesmb.app.confirmations import build_confirmation from timecapsulesmb import repair_xattrs as repair_xattrs_domain from timecapsulesmb.app import contracts, helper, service @@ -698,7 +699,7 @@ def test_dispatcher_maps_recoverable_and_unexpected_error_states(self) -> None: with self.subTest(code=code): collector = CollectingSink() - def fail(_params, _sink, exc=exception): + def fail(_params, _context, exc=exception): raise exc with mock.patch.dict(service.OPERATIONS, {operation: fail}): @@ -712,7 +713,7 @@ def fail(_params, _sink, exc=exception): def test_dispatcher_includes_traceback_for_unexpected_errors(self) -> None: collector = CollectingSink() - def fail(_params, _sink): + def fail(_params, _context): raise RuntimeError("boom") with mock.patch.dict(service.OPERATIONS, {"boom": fail}): @@ -727,8 +728,8 @@ def fail(_params, _sink): def test_dispatcher_emits_api_operation_telemetry(self) -> None: collector = CollectingSink() - def run_fsck(params, sink): - sink.stage("fsck", "run_fsck") + def run_fsck(params, context): + context.stage("run_fsck") return service.OperationResult(True, { "device": "/dev/dk2", "mountpoint": "/Volumes/Data", @@ -789,8 +790,8 @@ def run_fsck(params, sink): def test_dispatcher_emits_confirmation_required_telemetry(self) -> None: collector = CollectingSink() - def run_fsck(params, sink): - sink.stage("fsck", "select_fsck_volume") + def run_fsck(params, context): + context.stage("select_fsck_volume") raise service.AppConfirmationRequired(build_confirmation( operation="fsck", params=params, @@ -834,8 +835,8 @@ def test_dispatcher_does_not_emit_readiness_operation_telemetry(self) -> None: def test_app_api_telemetry_tests_do_not_open_network_connections(self) -> None: collector = CollectingSink() - def run_fsck(_params, sink): - sink.stage("fsck", "run_fsck") + def run_fsck(_params, context): + context.stage("run_fsck") return service.OperationResult(True, {"returncode": 0, "summary": "fsck completed."}) with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): @@ -849,6 +850,59 @@ def run_fsck(_params, sink): self.assertEqual(self._telemetry_client.emit.call_count, 2) self._telemetry_urlopen.assert_not_called() + def test_dispatcher_failure_telemetry_uses_app_operation_context(self) -> None: + collector = CollectingSink() + + def run_fsck(_params, context): + context.stage("read_mast") + context.config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + context.connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + context.add_debug_fields(mast_candidates=[{"volume": "Data"}]) + raise service.AppOperationError("No writable MaSt volumes were found.", code="remote_error") + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + telemetry_error = self._telemetry_client.emit.call_args_list[-1].kwargs["error"] + self.assertIn("No writable MaSt volumes were found.", telemetry_error) + self.assertIn("Debug context:", telemetry_error) + self.assertIn("command=fsck", telemetry_error) + self.assertIn("stage=read_mast", telemetry_error) + self.assertIn("host=root@10.0.0.2", telemetry_error) + self.assertIn("TC_HOST=root@10.0.0.2", telemetry_error) + self.assertIn("mast_candidates=[{volume:Data}]", telemetry_error) + self.assertNotIn("TC_PASSWORD=pw", telemetry_error) + + def test_dispatcher_unsuccessful_result_telemetry_uses_app_operation_context(self) -> None: + collector = CollectingSink() + + def run_fsck(_params, context): + context.stage("run_fsck") + context.config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + context.connection = SshConnection("root@10.0.0.2", "pw", "") + return service.OperationResult(False, {"error": "fsck exited with status 8"}) + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + result = self.assert_single_terminal_event(collector, "result") + self.assertFalse(result["ok"]) + self.assertNotIn("Debug context:", result["payload"]["error"]) + telemetry_error = self._telemetry_client.emit.call_args_list[-1].kwargs["error"] + self.assertIn("fsck exited with status 8", telemetry_error) + self.assertIn("Debug context:", telemetry_error) + self.assertIn("command=fsck", telemetry_error) + self.assertIn("stage=run_fsck", telemetry_error) + self.assertNotIn("TC_PASSWORD=pw", telemetry_error) + def test_discover_operation_returns_snapshot_payload(self) -> None: collector = CollectingSink() snapshot = BonjourDiscoverySnapshot( @@ -2039,13 +2093,14 @@ def test_deploy_request_ssh_reboot_reports_timeout_when_request_error_is_require from timecapsulesmb.app.ops import deploy as deploy_ops collector = CollectingSink() + context = AppOperationContext("deploy", collector.sink) connection = SshConnection("root@10.0.0.2", "pw", "-o foo") with mock.patch( "timecapsulesmb.app.ops.deploy.remote_request_reboot", side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), ): with self.assertRaises(AppOperationError) as raised: - deploy_ops.request_ssh_reboot("deploy", collector.sink, connection, raise_on_request_error=True) + deploy_ops.request_ssh_reboot(context, connection, raise_on_request_error=True) self.assertEqual(raised.exception.code, "remote_error") self.assertIn("Timed out waiting for ssh command to finish: reboot", str(raised.exception)) From 3c7436085149bdf0524d04fe29a34ce884825141 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 27 May 2026 03:41:40 -0700 Subject: [PATCH 052/129] Add shared animated operation timeline --- .../Components/AnimatedProgressText.swift | 46 +++++++ .../Components/BlockingProgressOverlay.swift | 4 +- .../OperationTimelineStateIcon.swift | 80 +++++++++--- .../Components/OperationTimelineView.swift | 104 +++++++++++++++ .../Views/Dashboard/CheckupTab.swift | 30 +---- .../Views/Dashboard/FlashBootHookView.swift | 2 +- .../Views/Dashboard/InstallTab.swift | 29 +---- .../Views/Dashboard/MaintenanceTab.swift | 22 +--- .../Views/Dashboard/OverviewTab.swift | 30 ++++- .../Views/Shell/ActivityView.swift | 120 +++--------------- ...mator.swift => ProgressTextAnimator.swift} | 10 +- .../ActivityProgressTextAnimatorTests.swift | 80 ------------ .../ProgressTextAnimatorTests.swift | 36 ++++++ 13 files changed, 310 insertions(+), 283 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/AnimatedProgressText.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineView.swift rename macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/{ActivityProgressTextAnimator.swift => ProgressTextAnimator.swift} (75%) delete mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ProgressTextAnimatorTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/AnimatedProgressText.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/AnimatedProgressText.swift new file mode 100644 index 00000000..bc04e63d --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/AnimatedProgressText.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct AnimatedProgressText: View { + let message: String + let isRunning: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var phase = 0 + + private let timer = Timer.publish( + every: ProgressTextAnimator.frameInterval, + on: .main, + in: .common + ).autoconnect() + + var body: some View { + Text(animatedMessage) + .onChange(of: animationIdentity) { _, _ in + phase = 0 + } + .onReceive(timer) { _ in + advanceAnimation() + } + } + + private var animatedMessage: String { + ProgressTextAnimator.message(message, isRunning: shouldAnimate, phase: phase) ?? message + } + + private var animationIdentity: String? { + ProgressTextAnimator.shouldAnimate(message, isRunning: shouldAnimate) ? message : nil + } + + private var shouldAnimate: Bool { + isRunning && !reduceMotion + } + + private func advanceAnimation() { + guard animationIdentity != nil else { + if phase != 0 { + phase = 0 + } + return + } + phase = ProgressTextAnimator.nextPhase(after: phase) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift index 958dc273..8cf8ff6c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift @@ -22,12 +22,12 @@ struct BlockingProgressOverlay: View { .controlSize(.large) Text(progress.title) .font(.headline) - Text(progress.message) + AnimatedProgressText(message: progress.message, isRunning: true) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - Text(progress.detail ?? "") + AnimatedProgressText(message: progress.detail ?? "", isRunning: progress.detail?.isEmpty == false) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(2) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift index 55fd3255..a003d752 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift @@ -1,31 +1,37 @@ import SwiftUI -struct OperationTimelineStateIcon: View { - let state: OperationTimelineItem.State - - var body: some View { - icon - .frame(width: 16, height: 16) - .accessibilityLabel(accessibilityLabel) +enum OperationTimelineVisualStyle { + static func symbol(for state: OperationTimelineItem.State) -> String { + switch state { + case .pending: + return "circle" + case .running: + return "arrow.triangle.2.circlepath" + case .succeeded: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + } } - @ViewBuilder - private var icon: some View { + static func color(for state: OperationTimelineItem.State) -> Color { switch state { case .pending: - Image(systemName: "circle") + return .secondary case .running: - RotatingTimelineIcon() + return .accentColor case .succeeded: - Image(systemName: "checkmark.circle") + return .green case .warning: - Image(systemName: "exclamationmark.triangle") + return .yellow case .failed: - Image(systemName: "xmark.octagon") + return .red } } - private var accessibilityLabel: String { + static func accessibilityLabel(for state: OperationTimelineItem.State) -> String { switch state { case .pending: return "Pending" @@ -41,12 +47,54 @@ struct OperationTimelineStateIcon: View { } } +struct OperationTimelineStateIcon: View { + let state: OperationTimelineItem.State + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + icon + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(OperationTimelineVisualStyle.color(for: state)) + .frame(width: 20, height: 20) + .scaleEffect(scale) + .animation(animation, value: state) + .accessibilityLabel(OperationTimelineVisualStyle.accessibilityLabel(for: state)) + } + + @ViewBuilder + private var icon: some View { + switch state { + case .pending: + Image(systemName: OperationTimelineVisualStyle.symbol(for: state)) + case .running: + RotatingTimelineIcon() + case .succeeded, .warning, .failed: + Image(systemName: OperationTimelineVisualStyle.symbol(for: state)) + } + } + + private var scale: CGFloat { + switch state { + case .running: + return 1.05 + case .succeeded: + return 1.08 + case .pending, .warning, .failed: + return 1 + } + } + + private var animation: Animation? { + reduceMotion ? nil : .snappy(duration: 0.18) + } +} + private struct RotatingTimelineIcon: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var isRotating = false var body: some View { - Image(systemName: "arrow.triangle.2.circlepath") + Image(systemName: OperationTimelineVisualStyle.symbol(for: .running)) .rotationEffect(.degrees(!reduceMotion && isRotating ? 360 : 0)) .animation(animation, value: isRotating) .onAppear { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineView.swift new file mode 100644 index 00000000..b230a077 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct OperationTimelineListView: View { + let title: String? + let emptyMessage: String? + let items: [OperationTimelineItem] + let showsRowBackground: Bool + + init( + title: String? = nil, + emptyMessage: String? = nil, + items: [OperationTimelineItem], + showsRowBackground: Bool = true + ) { + self.title = title + self.emptyMessage = emptyMessage + self.items = items + self.showsRowBackground = showsRowBackground + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let title { + Text(title) + .font(.headline) + } + + if items.isEmpty { + if let emptyMessage { + Text(emptyMessage) + .font(.caption) + .foregroundStyle(.secondary) + .transition(.opacity) + } + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(items) { item in + OperationTimelineRow(item: item, showsBackground: showsRowBackground) + .transition(rowTransition) + } + } + } + } + .animation(.snappy(duration: 0.22), value: items) + } + + private var rowTransition: AnyTransition { + .opacity.combined(with: .move(edge: .top)) + } +} + +struct OperationTimelineRow: View { + let item: OperationTimelineItem + let showsBackground: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init(item: OperationTimelineItem, showsBackground: Bool = true) { + self.item = item + self.showsBackground = showsBackground + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + OperationTimelineStateIcon(state: item.state) + .frame(width: 22, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + AnimatedProgressText(message: item.title, isRunning: item.state == .running && !hasDetail) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + if let detail = item.detail, !detail.isEmpty { + AnimatedProgressText(message: detail, isRunning: item.state == .running) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 0) + } + .padding(.vertical, 5) + .padding(.horizontal, showsBackground ? 6 : 0) + .background(background) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .contentShape(Rectangle()) + .animation(reduceMotion ? nil : .snappy(duration: 0.18), value: item.state) + .accessibilityElement(children: .combine) + } + + private var hasDetail: Bool { + item.detail?.isEmpty == false + } + + @ViewBuilder + private var background: some View { + if showsBackground && item.state == .running { + OperationTimelineVisualStyle.color(for: item.state).opacity(0.10) + } else if showsBackground && item.state == .failed { + OperationTimelineVisualStyle.color(for: item.state).opacity(0.08) + } else { + Color.clear + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift index 91f6a1bc..2a6abace 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift @@ -38,7 +38,10 @@ struct CheckupTab: View { } if !presentation.timeline.isEmpty { - CheckupTimelineView(items: presentation.timeline) + OperationTimelineListView( + title: L10n.string("checkup.timeline.title"), + items: presentation.timeline + ) } if !presentation.summaryRows.isEmpty { @@ -99,31 +102,6 @@ private struct CheckupHeaderView: View { } } -private struct CheckupTimelineView: View { - let items: [OperationTimelineItem] - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.string("checkup.timeline.title")) - .font(.headline) - ForEach(items) { item in - HStack(alignment: .top, spacing: 8) { - OperationTimelineStateIcon(state: item.state) - VStack(alignment: .leading, spacing: 2) { - Text(item.title) - .font(.body.weight(.medium)) - if let detail = item.detail { - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - } -} - private struct CheckupDomainView: View { let domain: CheckupDomainPresentation diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift index 8ecfa745..52a39e1e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift @@ -14,7 +14,7 @@ struct FlashBootHookSection: View { VStack(alignment: .leading, spacing: 4) { Text(presentation.title) .font(.headline) - Text(presentation.message) + AnimatedProgressText(message: presentation.message, isRunning: store.isRunning) .font(.caption) .foregroundStyle(.secondary) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift index ab0b2020..a4147364 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift @@ -154,30 +154,11 @@ private struct InstallTimelineView: View { let presentation: InstallTimelinePresentation var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.string("install.timeline.title")) - .font(.headline) - if presentation.items.isEmpty { - Text(L10n.string("install.timeline.waiting")) - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(presentation.items) { item in - HStack(alignment: .top, spacing: 8) { - OperationTimelineStateIcon(state: item.state) - VStack(alignment: .leading, spacing: 2) { - Text(item.title) - .font(.body.weight(.medium)) - if let detail = item.detail { - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - } + OperationTimelineListView( + title: L10n.string("install.timeline.title"), + emptyMessage: L10n.string("install.timeline.waiting"), + items: presentation.items + ) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift index 0b3e0ae2..5d16ab53 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift @@ -309,24 +309,10 @@ struct MaintenanceTimelineView: View { let presentation: MaintenanceTimelinePresentation var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.string("maintenance.timeline.title")) - .font(.headline) - ForEach(presentation.items) { item in - HStack(alignment: .top, spacing: 8) { - OperationTimelineStateIcon(state: item.state) - VStack(alignment: .leading, spacing: 2) { - Text(item.title) - .font(.body.weight(.medium)) - if let detail = item.detail { - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } + OperationTimelineListView( + title: L10n.string("maintenance.timeline.title"), + items: presentation.items + ) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift index 92e15009..156d70a7 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift @@ -3,7 +3,7 @@ import SwiftUI private enum OverviewLayout { static let actionIconSize: CGFloat = 16 static let healthRowMinHeight: CGFloat = 64 - static let healthStatusIconSize: CGFloat = 18 + static let healthStatusIconSize: CGFloat = 20 } struct OverviewTab: View { @@ -166,10 +166,7 @@ private struct DashboardHealthSectionView: View { .font(.headline) ForEach(section.rows) { row in HStack(alignment: .top, spacing: 10) { - Image(systemName: row.status.systemImage) - .font(.caption.weight(.medium)) - .accessibilityLabel(row.status.title) - .frame(width: OverviewLayout.healthStatusIconSize, height: OverviewLayout.healthStatusIconSize) + DashboardHealthStatusIcon(status: row.status) VStack(alignment: .leading, spacing: 3) { HStack { Text(row.title) @@ -185,7 +182,7 @@ private struct DashboardHealthSectionView: View { .disabled(!isActionEnabled(action)) } } - Text(row.detail) + AnimatedProgressText(message: row.detail, isRunning: row.status == .running) .font(.caption) .foregroundStyle(.secondary) } @@ -198,3 +195,24 @@ private struct DashboardHealthSectionView: View { } } } + +private struct DashboardHealthStatusIcon: View { + let status: DashboardHealthStatus + + var body: some View { + icon + .accessibilityLabel(status.title) + } + + @ViewBuilder + private var icon: some View { + if status == .running { + OperationTimelineStateIcon(state: .running) + .frame(width: OverviewLayout.healthStatusIconSize, height: OverviewLayout.healthStatusIconSize) + } else { + Image(systemName: status.systemImage) + .font(.caption.weight(.medium)) + .frame(width: OverviewLayout.healthStatusIconSize, height: OverviewLayout.healthStatusIconSize) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift index d98a5e53..2547f08c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift @@ -4,21 +4,13 @@ struct ActivityCompactView: View { @ObservedObject var activityStore: ActivityStore @ObservedObject var registry: DeviceRegistryStore let context: ActivityDisplayContext - @State private var messageAnimationPhase = 0 - - private let messageAnimationTimer = Timer.publish( - every: ActivityProgressTextAnimator.frameInterval, - on: .main, - in: .common - ).autoconnect() var body: some View { let status = activityStore.compactStatus(for: context) - let hasLatestMessage = hasLatestMessage(status) HStack(spacing: 10) { Image(systemName: icon(for: status)) .foregroundStyle(iconColor(for: status)) - messageView(status, hasLatestMessage: hasLatestMessage) + messageView(status) Spacer() if let latestTimelineTitle = status.latestTimelineTitle { Text(latestTimelineTitle) @@ -29,22 +21,16 @@ struct ActivityCompactView: View { .padding(.horizontal) .padding(.vertical, 8) .background(Color.secondary.opacity(0.06)) - .onChange(of: ActivityProgressTextAnimator.animationIdentity(for: status)) { _, _ in - messageAnimationPhase = 0 - } - .onReceive(messageAnimationTimer) { _ in - advanceMessageAnimation(for: status) - } } @ViewBuilder - private func messageView(_ status: ActivityCompactStatus, hasLatestMessage: Bool) -> some View { - if hasLatestMessage { + private func messageView(_ status: ActivityCompactStatus) -> some View { + if let latestMessage = status.latestMessage, !latestMessage.isEmpty { VStack(alignment: .leading, spacing: 2) { Text(title(status)) .font(.caption.weight(.medium)) .lineLimit(1) - Text(latestMessage(status)) + AnimatedProgressText(message: latestMessage, isRunning: status.isRunning) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) @@ -59,24 +45,6 @@ struct ActivityCompactView: View { } } - private func latestMessage(_ status: ActivityCompactStatus) -> String { - ActivityProgressTextAnimator.message( - status.latestMessage, - isRunning: status.isRunning, - phase: messageAnimationPhase - ) ?? "" - } - - private func advanceMessageAnimation(for status: ActivityCompactStatus) { - guard ActivityProgressTextAnimator.animationIdentity(for: status) != nil else { - if messageAnimationPhase != 0 { - messageAnimationPhase = 0 - } - return - } - messageAnimationPhase = ActivityProgressTextAnimator.nextPhase(after: messageAnimationPhase) - } - private func title(_ status: ActivityCompactStatus) -> String { if case .device(let activeDeviceID) = status.scope, let profile = registry.profile(id: activeDeviceID) { @@ -85,13 +53,6 @@ struct ActivityCompactView: View { return status.operationTitle } - private func hasLatestMessage(_ status: ActivityCompactStatus) -> Bool { - guard let latestMessage = status.latestMessage else { - return false - } - return !latestMessage.isEmpty - } - private func icon(for status: ActivityCompactStatus) -> String { if status.requiresAttention { return "exclamationmark.triangle" @@ -194,14 +155,12 @@ private struct ActivityLaneCard: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 8) { - Image(systemName: icon) - .foregroundStyle(iconColor) - .frame(width: 18) + headerIcon VStack(alignment: .leading, spacing: 2) { Text(title(laneSnapshot.snapshot)) .font(.body.weight(.medium)) if let latestMessage = laneSnapshot.snapshot.latestMessage, !latestMessage.isEmpty { - Text(latestMessage) + AnimatedProgressText(message: latestMessage, isRunning: laneSnapshot.snapshot.isRunning) .font(.caption) .foregroundStyle(.secondary) } @@ -216,7 +175,7 @@ private struct ActivityLaneCard: View { .foregroundStyle(.secondary) } else { ForEach(laneSnapshot.snapshot.timeline) { item in - ActivityTimelineRow(item: item) + OperationTimelineRow(item: item, showsBackground: false) } } } @@ -227,6 +186,18 @@ private struct ActivityLaneCard: View { .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } + @ViewBuilder + private var headerIcon: some View { + if laneSnapshot.snapshot.isRunning && !laneSnapshot.isPendingConfirmation { + OperationTimelineStateIcon(state: .running) + .frame(width: 18) + } else { + Image(systemName: icon) + .foregroundStyle(iconColor) + .frame(width: 18) + } + } + private var icon: String { if laneSnapshot.isPendingConfirmation { return "exclamationmark.triangle" @@ -249,56 +220,3 @@ private struct ActivityLaneCard: View { return snapshot.operationTitle } } - -private struct ActivityTimelineRow: View { - let item: OperationTimelineItem - - var body: some View { - HStack(alignment: .top, spacing: 8) { - Image(systemName: itemIcon) - .foregroundStyle(itemColor) - .frame(width: 18) - VStack(alignment: .leading, spacing: 2) { - Text(item.title) - .font(.body.weight(.medium)) - if let detail = item.detail, !detail.isEmpty { - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer() - } - .padding(.vertical, 4) - } - - private var itemIcon: String { - switch item.state { - case .pending: - return "circle" - case .running: - return "arrow.right.circle" - case .succeeded: - return "checkmark.circle" - case .warning: - return "exclamationmark.triangle" - case .failed: - return "xmark.octagon" - } - } - - private var itemColor: Color { - switch item.state { - case .pending: - return .secondary - case .running: - return .accentColor - case .succeeded: - return .green - case .warning: - return .yellow - case .failed: - return .red - } - } -} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ProgressTextAnimator.swift similarity index 75% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ProgressTextAnimator.swift index 19deb90f..131d118e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityProgressTextAnimator.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ProgressTextAnimator.swift @@ -1,6 +1,6 @@ import Foundation -enum ActivityProgressTextAnimator { +enum ProgressTextAnimator { static let frameInterval: TimeInterval = 0.3 static let frameCount = 3 @@ -21,14 +21,6 @@ enum ActivityProgressTextAnimator { return true } - static func animationIdentity(for snapshot: ActivitySnapshot) -> String? { - shouldAnimate(snapshot.latestMessage, isRunning: snapshot.isRunning) ? snapshot.latestMessage : nil - } - - static func animationIdentity(for status: ActivityCompactStatus) -> String? { - shouldAnimate(status.latestMessage, isRunning: status.isRunning) ? status.latestMessage : nil - } - static func nextPhase(after phase: Int) -> Int { (frameIndex(phase) + 1) % frameCount } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift deleted file mode 100644 index 268438ad..00000000 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityProgressTextAnimatorTests.swift +++ /dev/null @@ -1,80 +0,0 @@ -import XCTest -@testable import TimeCapsuleSMBApp - -final class ActivityProgressTextAnimatorTests: XCTestCase { - func testRunningMessageCyclesOneTwoAndThreeDots() { - let message = "Run local and remote diagnostic checks." - - XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: true, phase: 0), "Run local and remote diagnostic checks.") - XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: true, phase: 1), "Run local and remote diagnostic checks..") - XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: true, phase: 2), "Run local and remote diagnostic checks...") - XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: true, phase: 3), "Run local and remote diagnostic checks.") - } - - func testRunningMessageNormalizesExistingDotsBeforeAnimating() { - XCTAssertEqual(ActivityProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 0), "Resolve target.") - XCTAssertEqual(ActivityProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 1), "Resolve target..") - XCTAssertEqual(ActivityProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 2), "Resolve target...") - } - - func testInactiveMessagesRemainStable() { - let message = "deployment completed." - - XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: false, phase: 0), message) - XCTAssertEqual(ActivityProgressTextAnimator.message(message, isRunning: false, phase: 2), message) - } - - func testEmptyMessagesDoNotAnimate() { - XCTAssertNil(ActivityProgressTextAnimator.message(nil, isRunning: true, phase: 1)) - XCTAssertEqual(ActivityProgressTextAnimator.message("", isRunning: true, phase: 1), "") - XCTAssertEqual(ActivityProgressTextAnimator.message(" ", isRunning: true, phase: 1), " ") - } - - func testAnimationIdentityExistsOnlyForActiveMessages() { - let running = ActivitySnapshot( - isRunning: true, - scope: .app, - operationTitle: "Checkup", - latestMessage: "Run local and remote diagnostic checks.", - timeline: [] - ) - let completed = ActivitySnapshot( - isRunning: false, - scope: .app, - operationTitle: "Checkup", - latestMessage: "Run local and remote diagnostic checks.", - timeline: [] - ) - - XCTAssertEqual(ActivityProgressTextAnimator.animationIdentity(for: running), "Run local and remote diagnostic checks.") - XCTAssertNil(ActivityProgressTextAnimator.animationIdentity(for: completed)) - } - - func testCompactStatusAnimationIdentityExistsOnlyForActiveMessages() { - let running = ActivityCompactStatus( - isRunning: true, - requiresAttention: false, - scope: .app, - operationTitle: "Checkup", - latestMessage: "Run local and remote diagnostic checks.", - latestTimelineTitle: "Running Checkup", - activeLaneCount: 1 - ) - let completed = ActivityCompactStatus( - isRunning: false, - requiresAttention: false, - scope: .app, - operationTitle: "Checkup", - latestMessage: "Run local and remote diagnostic checks.", - latestTimelineTitle: "Done", - activeLaneCount: 0 - ) - - XCTAssertEqual(ActivityProgressTextAnimator.animationIdentity(for: running), "Run local and remote diagnostic checks.") - XCTAssertNil(ActivityProgressTextAnimator.animationIdentity(for: completed)) - } - - func testFrameIntervalMatchesBottomBarAnimationCadence() { - XCTAssertEqual(ActivityProgressTextAnimator.frameInterval, 0.3) - } -} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ProgressTextAnimatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ProgressTextAnimatorTests.swift new file mode 100644 index 00000000..28ef950b --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ProgressTextAnimatorTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class ProgressTextAnimatorTests: XCTestCase { + func testRunningMessageCyclesOneTwoAndThreeDots() { + let message = "Run local and remote diagnostic checks." + + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: true, phase: 0), "Run local and remote diagnostic checks.") + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: true, phase: 1), "Run local and remote diagnostic checks..") + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: true, phase: 2), "Run local and remote diagnostic checks...") + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: true, phase: 3), "Run local and remote diagnostic checks.") + } + + func testRunningMessageNormalizesExistingDotsBeforeAnimating() { + XCTAssertEqual(ProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 0), "Resolve target.") + XCTAssertEqual(ProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 1), "Resolve target..") + XCTAssertEqual(ProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 2), "Resolve target...") + } + + func testInactiveMessagesRemainStable() { + let message = "deployment completed." + + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: false, phase: 0), message) + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: false, phase: 2), message) + } + + func testEmptyMessagesDoNotAnimate() { + XCTAssertNil(ProgressTextAnimator.message(nil, isRunning: true, phase: 1)) + XCTAssertEqual(ProgressTextAnimator.message("", isRunning: true, phase: 1), "") + XCTAssertEqual(ProgressTextAnimator.message(" ", isRunning: true, phase: 1), " ") + } + + func testFrameIntervalMatchesProgressTextAnimationCadence() { + XCTAssertEqual(ProgressTextAnimator.frameInterval, 0.3) + } +} From ae9fc1a0e682cf6fe7a97a7cf871c783c55b0894 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 27 May 2026 04:05:53 -0700 Subject: [PATCH 053/129] Add Time Capsule sidebar context menu --- .../Resources/en.lproj/Localizable.strings | 5 + .../zh-Hans.lproj/Localizable.strings | 5 + .../Components/BlockingProgressOverlay.swift | 4 +- .../Views/Shell/ContentView.swift | 72 ++++++++- .../Views/Shell/SidebarView.swift | 35 ++++ ...DeviceSidebarContextMenuPresentation.swift | 150 ++++++++++++++++++ .../DashboardPresentationTests.swift | 124 +++++++++++++++ 7 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSidebarContextMenuPresentation.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 07ced7f3..2c4851cc 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -551,6 +551,11 @@ "sidebar.all_time_capsules" = "All Time Capsules"; "sidebar.activity" = "Activity"; "sidebar.devices" = "Devices"; +"sidebar.menu.copy_hostname" = "Copy Hostname"; +"sidebar.menu.copy_ip_address" = "Copy IP Address"; +"sidebar.menu.copy_smb_address" = "Copy SMB Address"; +"sidebar.menu.open_overview" = "Open Overview"; +"sidebar.menu.remove_from_this_mac" = "Remove From This Mac"; "sidebar.settings" = "Settings"; "status.activation_needed" = "Activation Needed"; "status.checking" = "Checking"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings index bfb5cdfd..47e8a51d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings @@ -551,6 +551,11 @@ "sidebar.all_time_capsules" = "所有 Time Capsule"; "sidebar.activity" = "Activity"; "sidebar.devices" = "设备"; +"sidebar.menu.copy_hostname" = "复制 Hostname"; +"sidebar.menu.copy_ip_address" = "复制 IP Address"; +"sidebar.menu.copy_smb_address" = "复制 SMB Address"; +"sidebar.menu.open_overview" = "打开 Overview"; +"sidebar.menu.remove_from_this_mac" = "从此 Mac 移除"; "sidebar.settings" = "设置"; "status.activation_needed" = "需要 Activation"; "status.checking" = "正在检查"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift index 8cf8ff6c..958dc273 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift @@ -22,12 +22,12 @@ struct BlockingProgressOverlay: View { .controlSize(.large) Text(progress.title) .font(.headline) - AnimatedProgressText(message: progress.message, isRunning: true) + Text(progress.message) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - AnimatedProgressText(message: progress.detail ?? "", isRunning: progress.detail?.isEmpty == false) + Text(progress.detail ?? "") .font(.caption2) .foregroundStyle(.secondary) .lineLimit(2) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift index 4ea206d0..821f9426 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift @@ -255,11 +255,19 @@ public struct ContentView: View { Section(L10n.string("sidebar.devices")) { ForEach(appStore.deviceRegistry.profiles) { profile in + let summary = appStore.dashboardSummary(for: profile) DeviceSidebarRow( profile: profile, - summary: appStore.dashboardSummary(for: profile), + summary: summary, lastSeenText: appStore.discoveryMonitor.lastSeenText(for: profile) ) + .contextMenu { + DeviceSidebarContextMenu( + presentation: sidebarContextMenuPresentation(for: profile, summary: summary) + ) { action in + performSidebarContextMenuAction(action, profile: profile) + } + } .tag("device:\(profile.id)") } } @@ -273,6 +281,68 @@ public struct ContentView: View { .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 360) } + private func sidebarContextMenuPresentation( + for profile: DeviceProfile, + summary: DeviceDashboardSummary + ) -> DeviceSidebarContextMenuPresentation { + DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: appStore.operationCoordinator.lane(for: profile).isBusy + ) + } + + private func performSidebarContextMenuAction( + _ action: DeviceSidebarContextMenuAction, + profile: DeviceProfile + ) { + switch action { + case .openOverview: + openDashboard(profile, tab: .overview) + case .openFinder: + dashboardStore.session(for: profile).performSecondaryAction(.openFinder, profile: profile) + case .runCheckup: + guard !appStore.operationCoordinator.lane(for: profile).isBusy else { + return + } + appStore.select(profile) + dashboardStore.session(for: profile).performSecondaryAction(.runCheckup, profile: profile) + case .viewCheckup: + openDashboard(profile, tab: .checkup) + case .refreshStatus: + guard !appStore.operationCoordinator.lane(for: profile).isBusy else { + return + } + dashboardStore.session(for: profile).performSecondaryAction(.refreshStatus, profile: profile) + case .settings: + openDashboard(profile, tab: .settings) + case .copySMBAddress, .copyHostname, .copyIPAddress: + copySidebarValue(action, profile: profile) + case .removeFromThisMac: + guard !appStore.operationCoordinator.lane(for: profile).isBusy else { + return + } + profilePendingDeletion = profile + } + } + + private func openDashboard(_ profile: DeviceProfile, tab: DeviceDashboardTab) { + let session = dashboardStore.session(for: profile) + session.selectedTab = tab + appStore.select(profile) + } + + private func copySidebarValue(_ action: DeviceSidebarContextMenuAction, profile: DeviceProfile) { + let summary = appStore.dashboardSummary(for: profile) + let presentation = sidebarContextMenuPresentation(for: profile, summary: summary) + guard let value = presentation.clipboardValue(for: action) else { + return + } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(value, forType: .string) + } + private var activityDisplayContext: ActivityDisplayContext { ActivityDisplayContext( selectedDeviceID: appStore.selectedDeviceID, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift index 2b5b50ae..6c8a8c80 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift @@ -44,3 +44,38 @@ struct DeviceSidebarRow: View { } } } + +struct DeviceSidebarContextMenu: View { + let presentation: DeviceSidebarContextMenuPresentation + let performAction: (DeviceSidebarContextMenuAction) -> Void + + var body: some View { + ForEach(presentation.navigationItems) { item in + menuButton(item) + } + + Divider() + + ForEach(presentation.clipboardItems) { item in + menuButton(item) + } + + Divider() + + ForEach(presentation.destructiveItems) { item in + menuButton(item, role: .destructive) + } + } + + private func menuButton( + _ item: DeviceSidebarContextMenuItem, + role: ButtonRole? = nil + ) -> some View { + Button(role: role) { + performAction(item.action) + } label: { + Label(item.title, systemImage: item.systemImage) + } + .disabled(!item.isEnabled) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSidebarContextMenuPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSidebarContextMenuPresentation.swift new file mode 100644 index 00000000..d05a6c8f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSidebarContextMenuPresentation.swift @@ -0,0 +1,150 @@ +import Foundation + +enum DeviceSidebarContextMenuAction: String, Equatable, Hashable, Identifiable { + case openOverview + case openFinder + case runCheckup + case viewCheckup + case refreshStatus + case settings + case copySMBAddress + case copyHostname + case copyIPAddress + case removeFromThisMac + + var id: String { rawValue } + + var title: String { + switch self { + case .openOverview: + return L10n.string("sidebar.menu.open_overview") + case .openFinder: + return L10n.string("dashboard.action.open_finder") + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .viewCheckup: + return L10n.string("dashboard.action.view_checkup") + case .refreshStatus: + return L10n.string("dashboard.action.refresh_status") + case .settings: + return L10n.string("dashboard.action.settings") + case .copySMBAddress: + return L10n.string("sidebar.menu.copy_smb_address") + case .copyHostname: + return L10n.string("sidebar.menu.copy_hostname") + case .copyIPAddress: + return L10n.string("sidebar.menu.copy_ip_address") + case .removeFromThisMac: + return L10n.string("sidebar.menu.remove_from_this_mac") + } + } + + var systemImage: String { + switch self { + case .openOverview: + return "rectangle.grid.1x2" + case .openFinder: + return "folder" + case .runCheckup: + return "stethoscope" + case .viewCheckup: + return "list.bullet.clipboard" + case .refreshStatus: + return "arrow.clockwise" + case .settings: + return "gearshape" + case .copySMBAddress: + return "link" + case .copyHostname: + return "network" + case .copyIPAddress: + return "number" + case .removeFromThisMac: + return "trash" + } + } +} + +struct DeviceSidebarContextMenuItem: Equatable, Identifiable { + let action: DeviceSidebarContextMenuAction + let isEnabled: Bool + + var id: DeviceSidebarContextMenuAction { action } + var title: String { action.title } + var systemImage: String { action.systemImage } +} + +struct DeviceSidebarContextMenuPresentation: Equatable { + let navigationItems: [DeviceSidebarContextMenuItem] + let clipboardItems: [DeviceSidebarContextMenuItem] + let destructiveItems: [DeviceSidebarContextMenuItem] + private let clipboardValues: [DeviceSidebarContextMenuAction: String] + + init(profile: DeviceProfile, summary: DeviceDashboardSummary, isDeviceBusy: Bool) { + var navigationItems = [ + DeviceSidebarContextMenuItem(action: .openOverview, isEnabled: true) + ] + let smbAddress = SMBAddressPolicy.url(for: profile)?.absoluteString + let hostname = Self.hostname(for: profile) + let ipAddress = Self.ipAddress(for: profile) + navigationItems.append(DeviceSidebarContextMenuItem(action: .openFinder, isEnabled: smbAddress != nil)) + navigationItems.append(Self.checkupItem(summary: summary, isDeviceBusy: isDeviceBusy)) + navigationItems.append(DeviceSidebarContextMenuItem( + action: .refreshStatus, + isEnabled: !isDeviceBusy && DashboardActionPolicy.isEnabled(.refreshStatus, for: summary) + )) + navigationItems.append(DeviceSidebarContextMenuItem(action: .settings, isEnabled: true)) + self.navigationItems = navigationItems + + self.clipboardItems = [ + DeviceSidebarContextMenuItem(action: .copySMBAddress, isEnabled: smbAddress != nil), + DeviceSidebarContextMenuItem(action: .copyHostname, isEnabled: hostname != nil), + DeviceSidebarContextMenuItem(action: .copyIPAddress, isEnabled: ipAddress != nil) + ] + self.clipboardValues = [ + .copySMBAddress: smbAddress, + .copyHostname: hostname, + .copyIPAddress: ipAddress + ].compactMapValues { $0 } + + self.destructiveItems = [ + DeviceSidebarContextMenuItem(action: .removeFromThisMac, isEnabled: !isDeviceBusy) + ] + } + + func clipboardValue(for action: DeviceSidebarContextMenuAction) -> String? { + clipboardValues[action] + } + + private static func checkupItem( + summary: DeviceDashboardSummary, + isDeviceBusy: Bool + ) -> DeviceSidebarContextMenuItem { + if summary.displayStatus == .checking { + return DeviceSidebarContextMenuItem(action: .viewCheckup, isEnabled: true) + } + return DeviceSidebarContextMenuItem( + action: .runCheckup, + isEnabled: summary.passwordState == .available + && !isDeviceBusy + && DashboardActionPolicy.isEnabled(DashboardSecondaryAction.runCheckup, for: summary) + ) + } + + private static func hostname(for profile: DeviceProfile) -> String? { + [ + profile.hostname, + profile.host + ] + .compactMap(DeviceEndpointPolicy.normalizedHostname) + .first + } + + private static func ipAddress(for profile: DeviceProfile) -> String? { + let regular = profile.network.addresses.filter { $0.scope == .regular } + return regular.first { $0.family == .ipv4 }?.value + ?? regular.first { $0.family == .ipv6 }?.value + ?? profile.network.addresses.first { $0.family == .ipv4 }?.value + ?? profile.network.addresses.first { $0.family == .ipv6 }?.value + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift index e2a961bf..f6c4b968 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -84,6 +84,128 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.viewDiagnostics, store: store)) } + func testSidebarContextMenuIncludesRequestedActionsAndCopyValues() throws { + var profile = try makeProfile(host: "root@10.0.0.2") + profile.hostname = "airport-time-capsule.local." + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + ) + + let presentation = DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: false + ) + + XCTAssertEqual( + presentation.navigationItems.map(\.action), + [.openOverview, .openFinder, .runCheckup, .refreshStatus, .settings] + ) + XCTAssertTrue(presentation.navigationItems.allSatisfy(\.isEnabled)) + XCTAssertEqual( + presentation.clipboardItems.map(\.action), + [.copySMBAddress, .copyHostname, .copyIPAddress] + ) + XCTAssertEqual(presentation.clipboardValue(for: .copySMBAddress), "smb://airport-time-capsule.local") + XCTAssertEqual(presentation.clipboardValue(for: .copyHostname), "airport-time-capsule.local") + XCTAssertEqual(presentation.clipboardValue(for: .copyIPAddress), "10.0.0.2") + XCTAssertEqual(presentation.destructiveItems, [ + DeviceSidebarContextMenuItem(action: .removeFromThisMac, isEnabled: true) + ]) + } + + func testSidebarContextMenuSwitchesCheckupActionAndDisablesBusyActions() throws { + let profile = try makeProfile() + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .checking, + primaryAction: .viewCheckup, + hostWarning: nil + ) + + let presentation = DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: true + ) + + XCTAssertTrue(presentation.navigationItems.contains(DeviceSidebarContextMenuItem(action: .viewCheckup, isEnabled: true))) + XCTAssertFalse(presentation.navigationItems.contains { $0.action == .runCheckup }) + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .refreshStatus }?.isEnabled, + false + ) + XCTAssertEqual(presentation.destructiveItems, [ + DeviceSidebarContextMenuItem(action: .removeFromThisMac, isEnabled: false) + ]) + } + + func testSidebarContextMenuDisablesRunCheckupWhenDeviceLaneIsBusyWithoutCheckingStatus() throws { + let profile = try makeProfile() + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + ) + + let presentation = DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: true + ) + + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .runCheckup }, + DeviceSidebarContextMenuItem(action: .runCheckup, isEnabled: false) + ) + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .refreshStatus }, + DeviceSidebarContextMenuItem(action: .refreshStatus, isEnabled: false) + ) + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .openFinder }, + DeviceSidebarContextMenuItem(action: .openFinder, isEnabled: true) + ) + } + + func testSidebarContextMenuDisablesUnavailableActionsAndCopyValues() throws { + let profile = try makeProfile(host: "airport-time-capsule.local") + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .missing, + displayStatus: .passwordNeeded, + primaryAction: .replacePassword, + hostWarning: nil + ) + + let presentation = DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: false + ) + + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .runCheckup }, + DeviceSidebarContextMenuItem(action: .runCheckup, isEnabled: false) + ) + XCTAssertEqual( + presentation.clipboardItems.map(\.action), + [.copySMBAddress, .copyHostname, .copyIPAddress] + ) + XCTAssertEqual( + presentation.clipboardItems.first { $0.action == .copyIPAddress }?.isEnabled, + false + ) + XCTAssertNil(presentation.clipboardValue(for: .copyIPAddress)) + } + func testDoctorDomainPolicyUsesTypedDetailsDomainAndSeverity() throws { let payload = try testDoctorPayload(checks: [ testDoctorCheck(status: "PASS", message: "ssh ok", domain: "Device"), @@ -861,6 +983,7 @@ final class DashboardPresentationTests: XCTestCase { private func makeProfile( id: String = "device-one", + host: String = "10.0.0.2", payloadFamily: String = "netbsd6_samba4", syap: String = "119", model: String = "Time Capsule", @@ -869,6 +992,7 @@ final class DashboardPresentationTests: XCTestCase { DeviceProfile.make( id: id, configuredDevice: try testConfiguredDevice( + host: host, syap: syap, model: model, payloadFamily: payloadFamily, From 3cebe3ee96850f77ad326f928015c05af1af132b Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 27 May 2026 04:28:45 -0700 Subject: [PATCH 054/129] Show plan and run actions side by side in GUI workflows --- .../Views/Dashboard/InstallTab.swift | 25 +++- .../Views/Dashboard/MaintenanceTab.swift | 65 ++++----- .../Workflows/InstallPresentation.swift | 32 ++-- .../Workflows/MaintenancePresentation.swift | 107 ++++++-------- .../Workflows/MaintenanceStore.swift | 12 ++ .../DashboardPresentationTests.swift | 137 ++++++++---------- 6 files changed, 193 insertions(+), 185 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift index a4147364..4a2ac3ff 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift @@ -35,11 +35,15 @@ struct InstallTab: View { .foregroundStyle(.yellow) } - if let action = presentation.primaryAction { - InstallActionButton(action: action) { - session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + if !presentation.actions.isEmpty { + HStack { + ForEach(presentation.actions) { action in + InstallActionButton(action: action) { + session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + } + .disabled(isDisabled(action, store: store)) + } } - .disabled(isDisabled(action, store: store)) } if let timeline = presentation.timeline { @@ -118,10 +122,17 @@ private struct InstallActionButton: View { let perform: () -> Void var body: some View { - Button(action: perform) { - Label(action.title, systemImage: action.systemImage) + if action == .installUpdate { + Button(action: perform) { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.borderedProminent) + } else { + Button(action: perform) { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.bordered) } - .buttonStyle(.borderedProminent) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift index 5d16ab53..6ba78341 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift @@ -172,22 +172,12 @@ private struct MaintenanceDetailView: View { } HStack { - if let action = presentation.primaryAction { - Button { - performAction(action) - } label: { - Label(action.title, systemImage: action.systemImage) - } - .buttonStyle(.borderedProminent) - .disabled(isDisabled(action)) - } - ForEach(presentation.secondaryActions) { action in - Button { - performAction(action) - } label: { - Label(action.title, systemImage: action.systemImage) - } - .disabled(isDisabled(action)) + ForEach(presentation.actions) { action in + MaintenanceActionButton( + action: action, + isEnabled: presentation.isEnabled(action), + perform: performAction + ) } } @@ -210,25 +200,30 @@ private struct MaintenanceDetailView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) } - private func isDisabled(_ action: MaintenanceUserAction) -> Bool { - if store.isRunning { - return true - } - switch action { - case .runActivation: - return !store.canRunActivation - case .runUninstall: - return !store.canRunUninstall - case .planFsck: - return !store.canPlanFsck - case .runFsck: - return !store.canRunFsck - case .repairMetadata: - return !store.canRepairXattrs - case .scanMetadata: - return !store.canScanRepairXattrs - case .planActivation, .planUninstall, .findVolumes, .viewDiagnostics: - return false +} + +private struct MaintenanceActionButton: View { + let action: MaintenanceUserAction + let isEnabled: Bool + let perform: (MaintenanceUserAction) -> Void + + var body: some View { + if action.isCommitAction { + Button { + perform(action) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.borderedProminent) + .disabled(!isEnabled) + } else { + Button { + perform(action) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.bordered) + .disabled(!isEnabled) } } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift index 372642ba..10d1759a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift @@ -276,7 +276,7 @@ struct InstallWorkflowPresentation: Equatable { let title: String let stateTitle: String let statusMessage: String - let primaryAction: InstallUserAction? + let actions: [InstallUserAction] let notices: [String] let plan: InstallPlanPresentation? let timeline: InstallTimelinePresentation? @@ -313,47 +313,57 @@ struct InstallWorkflowPresentation: Equatable { case .idle: if persistedCompletion == nil { self.statusMessage = L10n.string("install.state.idle") - self.primaryAction = .createPlan + self.actions = Self.planAndDeployActions(state: state, plan: plan) } else { self.statusMessage = L10n.string("install.state.deployed") - self.primaryAction = nil + self.actions = [] } self.notices = [] case .planning: self.statusMessage = L10n.string("install.state.planning") - self.primaryAction = nil + self.actions = Self.planAndDeployActions(state: state, plan: plan) self.notices = [] case .planReady: self.statusMessage = L10n.string("install.state.plan_ready") - self.primaryAction = plan == nil ? .createPlan : .installUpdate + self.actions = Self.planAndDeployActions(state: state, plan: plan) self.notices = [] case .planStale: self.statusMessage = L10n.string("install.state.plan_stale") - self.primaryAction = .regeneratePlan + self.actions = Self.planAndDeployActions(state: state, plan: plan) self.notices = [L10n.string("install.warning.plan_stale")] case .planFailed: self.statusMessage = error?.message ?? L10n.string("install.state.plan_failed") - self.primaryAction = .createPlan + self.actions = Self.planAndDeployActions(state: state, plan: plan) self.notices = [] case .deploying: self.statusMessage = L10n.string("install.state.deploying") - self.primaryAction = nil + self.actions = Self.planAndDeployActions(state: state, plan: plan) self.notices = [] case .awaitingConfirmation: self.statusMessage = L10n.string("install.state.awaiting_confirmation") - self.primaryAction = nil + self.actions = Self.planAndDeployActions(state: state, plan: plan) self.notices = [L10n.string("install.warning.awaiting_confirmation")] case .deployed: self.statusMessage = L10n.string("install.state.deployed") - self.primaryAction = nil + self.actions = [] self.notices = [] case .deployFailed: self.statusMessage = error?.message ?? L10n.string("install.state.deploy_failed") - self.primaryAction = .regeneratePlan + self.actions = Self.planAndDeployActions(state: state, plan: plan) self.notices = [] } } + private static func planAndDeployActions(state: DeployWorkflowState, plan: DeployPlanPayload?) -> [InstallUserAction] { + let planAction: InstallUserAction + if plan != nil || state == .planStale || state == .deployFailed { + planAction = .regeneratePlan + } else { + planAction = .createPlan + } + return [planAction, .installUpdate] + } + private static func persistedCompletion( state: DeployWorkflowState, result: DeployResultPayload?, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift index 11d40e5c..cba75465 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift @@ -59,6 +59,15 @@ enum MaintenanceUserAction: String, Equatable, Identifiable { return "wrench.and.screwdriver" } } + + var isCommitAction: Bool { + switch self { + case .runActivation, .runUninstall, .runFsck, .repairMetadata: + return true + case .planActivation, .planUninstall, .findVolumes, .planFsck, .scanMetadata, .viewDiagnostics: + return false + } + } } struct MaintenanceWorkflowCardPresentation: Equatable, Identifiable { @@ -110,71 +119,52 @@ struct MaintenanceTimelinePresentation: Equatable { } } -struct MaintenanceActionContext: Equatable { - let workflow: MaintenanceWorkflow - let state: MaintenanceOperationState - let hasSelectedFsckTarget: Bool - let canRepairXattrs: Bool -} - enum MaintenanceActionPolicy { - static func primaryAction(for context: MaintenanceActionContext) -> MaintenanceUserAction? { - switch context.workflow { + static func actions(for workflow: MaintenanceWorkflow) -> [MaintenanceUserAction] { + switch workflow { case .activate: - switch context.state { - case .idle, .failed, .succeeded: - return .planActivation - case .planReady: - return .runActivation - default: - return nil - } + return [.planActivation, .runActivation] case .uninstall: - switch context.state { - case .idle, .failed, .succeeded, .planStale: - return .planUninstall - case .planReady: - return .runUninstall - default: - return nil - } + return [.planUninstall, .runUninstall] case .fsck: - switch context.state { - case .idle, .failed, .succeeded: - return .findVolumes - case .listReady: - return context.hasSelectedFsckTarget ? .planFsck : nil - case .planStale: - return .planFsck - case .planReady: - return .runFsck - default: - return nil - } + return [.findVolumes, .planFsck, .runFsck] case .repairXattrs: - switch context.state { - case .idle, .failed, .repaired, .scanStale: - return .scanMetadata - case .scanReady: - return context.canRepairXattrs ? .repairMetadata : .scanMetadata - default: - return nil - } + return [.scanMetadata, .repairMetadata] } } - static func secondaryActions(workflow: MaintenanceWorkflow, state: MaintenanceOperationState) -> [MaintenanceUserAction] { + @MainActor + static func enabledActions(workflow: MaintenanceWorkflow, store: MaintenanceStore) -> Set { switch workflow { case .activate: - return state == .planReady ? [.planActivation] : [] + return enabled([ + (.planActivation, store.canPlanActivation), + (.runActivation, store.canRunActivation) + ]) case .uninstall: - return state == .planReady ? [.planUninstall] : [] + return enabled([ + (.planUninstall, store.canPlanUninstall), + (.runUninstall, store.canRunUninstall) + ]) case .fsck: - return state == .planReady ? [.planFsck, .findVolumes] : state == .listReady ? [.findVolumes] : [] + return enabled([ + (.findVolumes, store.canFindFsckVolumes), + (.planFsck, store.canPlanFsck), + (.runFsck, store.canRunFsck) + ]) case .repairXattrs: - return state == .scanReady ? [.scanMetadata] : [] + return enabled([ + (.scanMetadata, store.canScanRepairXattrs), + (.repairMetadata, store.canRepairXattrs) + ]) } } + + private static func enabled(_ pairs: [(MaintenanceUserAction, Bool)]) -> Set { + Set(pairs.compactMap { action, isEnabled in + isEnabled ? action : nil + }) + } } extension MaintenanceOperationState { @@ -219,8 +209,8 @@ struct MaintenanceWorkflowDetailPresentation: Equatable { let risk: String let stateTitle: String let statusMessage: String - let primaryAction: MaintenanceUserAction? - let secondaryActions: [MaintenanceUserAction] + let actions: [MaintenanceUserAction] + let enabledActions: Set let plan: MaintenancePlanPresentation? let completion: MaintenanceCompletionPresentation? let timeline: MaintenanceTimelinePresentation? @@ -236,18 +226,17 @@ struct MaintenanceWorkflowDetailPresentation: Equatable { self.risk = legacy.risk self.stateTitle = state.title self.statusMessage = state.maintenanceStatusMessage(for: workflow) - self.primaryAction = MaintenanceActionPolicy.primaryAction(for: MaintenanceActionContext( - workflow: workflow, - state: state, - hasSelectedFsckTarget: store.selectedFsckTarget != nil, - canRepairXattrs: store.canRepairXattrs - )) - self.secondaryActions = MaintenanceActionPolicy.secondaryActions(workflow: workflow, state: state) + self.actions = MaintenanceActionPolicy.actions(for: workflow) + self.enabledActions = MaintenanceActionPolicy.enabledActions(workflow: workflow, store: store) self.plan = Self.plan(workflow: workflow, store: store, profile: profile) self.completion = Self.completion(workflow: workflow, store: store) self.timeline = Self.timeline(workflow: workflow, state: state, store: store) } + func isEnabled(_ action: MaintenanceUserAction) -> Bool { + enabledActions.contains(action) + } + @MainActor private static func plan( workflow: MaintenanceWorkflow, diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift index 556c40a5..9ab5268d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift @@ -230,14 +230,26 @@ final class MaintenanceStore: ObservableObject { return fsckTargets.first { $0.id == selectedFsckTargetID } } + var canPlanActivation: Bool { + !isBusy + } + var canRunActivation: Bool { !isBusy && activationPlan != nil && activateState == .planReady } + var canPlanUninstall: Bool { + !isBusy && currentOptions != nil + } + var canRunUninstall: Bool { !isBusy && uninstallPlan != nil && uninstallState == .planReady && currentOptions == plannedUninstallOptions } + var canFindFsckVolumes: Bool { + !isBusy && mountWaitValue != nil + } + var canPlanFsck: Bool { !isBusy && selectedFsckTarget != nil && currentOptions != nil } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift index f6c4b968..c1074adc 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -586,16 +586,16 @@ final class DashboardPresentationTests: XCTestCase { let result = try testDeployResultPayload().decode(DeployResultPayload.self) let error = BackendErrorViewModel(operation: "deploy", code: "operation_failed", message: "failed") - let cases: [(DeployWorkflowState, DeployPlanPayload?, DeployResultPayload?, BackendErrorViewModel?, InstallUserAction?)] = [ - (.idle, nil, nil, nil, .createPlan), - (.planning, nil, nil, nil, nil), - (.planReady, plan, nil, nil, .installUpdate), - (.planStale, plan, nil, nil, .regeneratePlan), - (.planFailed, nil, nil, error, .createPlan), - (.deploying, plan, nil, nil, nil), - (.awaitingConfirmation, plan, nil, nil, nil), - (.deployed, plan, result, nil, nil), - (.deployFailed, plan, nil, error, .regeneratePlan) + let cases: [(DeployWorkflowState, DeployPlanPayload?, DeployResultPayload?, BackendErrorViewModel?, [InstallUserAction])] = [ + (.idle, nil, nil, nil, [.createPlan, .installUpdate]), + (.planning, nil, nil, nil, [.createPlan, .installUpdate]), + (.planReady, plan, nil, nil, [.regeneratePlan, .installUpdate]), + (.planStale, plan, nil, nil, [.regeneratePlan, .installUpdate]), + (.planFailed, nil, nil, error, [.createPlan, .installUpdate]), + (.deploying, plan, nil, nil, [.regeneratePlan, .installUpdate]), + (.awaitingConfirmation, plan, nil, nil, [.regeneratePlan, .installUpdate]), + (.deployed, plan, result, nil, []), + (.deployFailed, plan, nil, error, [.regeneratePlan, .installUpdate]) ] for testCase in cases { @@ -608,7 +608,7 @@ final class DashboardPresentationTests: XCTestCase { currentStage: nil, profile: profile ) - XCTAssertEqual(presentation.primaryAction, testCase.4, "Unexpected primary action for \(testCase.0)") + XCTAssertEqual(presentation.actions, testCase.4, "Unexpected actions for \(testCase.0)") } } @@ -636,7 +636,7 @@ final class DashboardPresentationTests: XCTestCase { let completion = try XCTUnwrap(presentation.completion) XCTAssertEqual(presentation.stateTitle, "Deployed") XCTAssertEqual(presentation.statusMessage, "Install / Update completed.") - XCTAssertNil(presentation.primaryAction) + XCTAssertEqual(presentation.actions, []) XCTAssertEqual(completion.title, "Install / Update Verified") XCTAssertTrue(completion.rows.contains(PresentationRow(label: "Reboot Requested", value: "no"))) XCTAssertTrue(completion.rows.contains(PresentationRow(label: "Message", value: "Installed from previous app session."))) @@ -692,7 +692,7 @@ final class DashboardPresentationTests: XCTestCase { profile: profile ) XCTAssertEqual(planReady.stateTitle, "Plan Ready") - XCTAssertEqual(planReady.primaryAction, .installUpdate) + XCTAssertEqual(planReady.actions, [.regeneratePlan, .installUpdate]) XCTAssertNil(planReady.completion) let deployFailed = InstallWorkflowPresentation( @@ -705,7 +705,7 @@ final class DashboardPresentationTests: XCTestCase { profile: profile ) XCTAssertEqual(deployFailed.stateTitle, "Deploy Failed") - XCTAssertEqual(deployFailed.primaryAction, .regeneratePlan) + XCTAssertEqual(deployFailed.actions, [.regeneratePlan, .installUpdate]) XCTAssertNil(deployFailed.completion) } @@ -783,49 +783,15 @@ final class DashboardPresentationTests: XCTestCase { } } - func testMaintenanceActionPolicyCoversAllStates() { - let expectedActivate: [MaintenanceOperationState: MaintenanceUserAction] = [ - .idle: .planActivation, - .planReady: .runActivation, - .succeeded: .planActivation, - .failed: .planActivation - ] - let expectedUninstall: [MaintenanceOperationState: MaintenanceUserAction] = [ - .idle: .planUninstall, - .planReady: .runUninstall, - .planStale: .planUninstall, - .succeeded: .planUninstall, - .failed: .planUninstall - ] - let expectedFsck: [MaintenanceOperationState: MaintenanceUserAction] = [ - .idle: .findVolumes, - .listReady: .planFsck, - .planReady: .runFsck, - .planStale: .planFsck, - .succeeded: .findVolumes, - .failed: .findVolumes - ] - let expectedRepair: [MaintenanceOperationState: MaintenanceUserAction] = [ - .idle: .scanMetadata, - .scanReady: .repairMetadata, - .scanStale: .scanMetadata, - .repaired: .scanMetadata, - .failed: .scanMetadata - ] - - for state in MaintenanceOperationState.allCases { - XCTAssertEqual(primaryAction(.activate, state: state), expectedActivate[state], "Unexpected activate action for \(state).") - XCTAssertEqual(primaryAction(.uninstall, state: state), expectedUninstall[state], "Unexpected uninstall action for \(state).") - XCTAssertEqual(primaryAction(.fsck, state: state), expectedFsck[state], "Unexpected fsck action for \(state).") - XCTAssertEqual(primaryAction(.repairXattrs, state: state), expectedRepair[state], "Unexpected repair action for \(state).") - } - - XCTAssertNil(primaryAction(.fsck, state: .listReady, hasSelectedFsckTarget: false)) - XCTAssertEqual(primaryAction(.repairXattrs, state: .scanReady, canRepairXattrs: false), .scanMetadata) - XCTAssertEqual(MaintenanceActionPolicy.secondaryActions(workflow: .fsck, state: .planReady), [.planFsck, .findVolumes]) - XCTAssertEqual(MaintenanceActionPolicy.secondaryActions(workflow: .repairXattrs, state: .scanReady), [.scanMetadata]) + func testMaintenanceActionPolicyUsesStableWorkflowActionGroups() { + XCTAssertEqual(MaintenanceActionPolicy.actions(for: .activate), [.planActivation, .runActivation]) + XCTAssertEqual(MaintenanceActionPolicy.actions(for: .uninstall), [.planUninstall, .runUninstall]) + XCTAssertEqual(MaintenanceActionPolicy.actions(for: .fsck), [.findVolumes, .planFsck, .runFsck]) + XCTAssertEqual(MaintenanceActionPolicy.actions(for: .repairXattrs), [.scanMetadata, .repairMetadata]) XCTAssertEqual(MaintenanceUserAction.planActivation.title, "Plan Activate") XCTAssertEqual(MaintenanceUserAction.runActivation.title, "Activate") + XCTAssertFalse(MaintenanceUserAction.planActivation.isCommitAction) + XCTAssertTrue(MaintenanceUserAction.runActivation.isCommitAction) } func testMaintenanceStatusMessagesCoverAllStates() { @@ -862,6 +828,36 @@ final class DashboardPresentationTests: XCTestCase { XCTAssertEqual(presentation.detail.workflow, .activate) } + func testMaintenancePresentationShowsRunActionsDisabledBeforePlanning() throws { + let store = MaintenanceStore(backend: BackendClient(runner: StoreTestRunner(responses: []))) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + + var presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .activate) + XCTAssertEqual(presentation.detail.actions, [.planActivation, .runActivation]) + XCTAssertTrue(presentation.detail.isEnabled(.planActivation)) + XCTAssertFalse(presentation.detail.isEnabled(.runActivation)) + + store.selectedWorkflow = .uninstall + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.actions, [.planUninstall, .runUninstall]) + XCTAssertTrue(presentation.detail.isEnabled(.planUninstall)) + XCTAssertFalse(presentation.detail.isEnabled(.runUninstall)) + + store.selectedWorkflow = .fsck + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.actions, [.findVolumes, .planFsck, .runFsck]) + XCTAssertTrue(presentation.detail.isEnabled(.findVolumes)) + XCTAssertFalse(presentation.detail.isEnabled(.planFsck)) + XCTAssertFalse(presentation.detail.isEnabled(.runFsck)) + + store.selectedWorkflow = .repairXattrs + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.actions, [.scanMetadata, .repairMetadata]) + XCTAssertFalse(presentation.detail.isEnabled(.scanMetadata)) + XCTAssertFalse(presentation.detail.isEnabled(.repairMetadata)) + } + func testMaintenancePresentationBuildsWorkflowPlansAndCompletions() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ @@ -890,7 +886,9 @@ final class DashboardPresentationTests: XCTestCase { try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } var presentation = MaintenanceDashboardPresentation(store: store, profile: profile) XCTAssertEqual(presentation.detail.workflow, .activate) - XCTAssertEqual(presentation.detail.primaryAction, .runActivation) + XCTAssertEqual(presentation.detail.actions, [.planActivation, .runActivation]) + XCTAssertTrue(presentation.detail.isEnabled(.planActivation)) + XCTAssertTrue(presentation.detail.isEnabled(.runActivation)) XCTAssertEqual(presentation.detail.plan?.title, "Activation Plan") XCTAssertEqual(presentation.detail.plan?.rows.first, PresentationRow(label: "Device", value: profile.title)) @@ -904,14 +902,19 @@ final class DashboardPresentationTests: XCTestCase { try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } presentation = MaintenanceDashboardPresentation(store: store, profile: profile) XCTAssertEqual(presentation.detail.workflow, .uninstall) - XCTAssertEqual(presentation.detail.primaryAction, .runUninstall) + XCTAssertEqual(presentation.detail.actions, [.planUninstall, .runUninstall]) + XCTAssertTrue(presentation.detail.isEnabled(.planUninstall)) + XCTAssertTrue(presentation.detail.isEnabled(.runUninstall)) XCTAssertEqual(presentation.detail.plan?.warnings, ["Uninstall removes managed SMB files from this Time Capsule."]) store.refreshFsckTargets(password: "pw") try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } presentation = MaintenanceDashboardPresentation(store: store, profile: profile) XCTAssertEqual(presentation.detail.workflow, .fsck) - XCTAssertEqual(presentation.detail.primaryAction, .planFsck) + XCTAssertEqual(presentation.detail.actions, [.findVolumes, .planFsck, .runFsck]) + XCTAssertTrue(presentation.detail.isEnabled(.findVolumes)) + XCTAssertTrue(presentation.detail.isEnabled(.planFsck)) + XCTAssertFalse(presentation.detail.isEnabled(.runFsck)) store.planFsck(password: "pw") try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } @@ -924,7 +927,9 @@ final class DashboardPresentationTests: XCTestCase { try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } presentation = MaintenanceDashboardPresentation(store: store, profile: profile) XCTAssertEqual(presentation.detail.workflow, .repairXattrs) - XCTAssertEqual(presentation.detail.primaryAction, .repairMetadata) + XCTAssertEqual(presentation.detail.actions, [.scanMetadata, .repairMetadata]) + XCTAssertTrue(presentation.detail.isEnabled(.scanMetadata)) + XCTAssertTrue(presentation.detail.isEnabled(.repairMetadata)) XCTAssertEqual(presentation.detail.plan?.title, "Metadata Scan") XCTAssertEqual(presentation.detail.plan?.warnings, ["Metadata repair modifies files under the selected local SMB mount."]) } @@ -959,20 +964,6 @@ final class DashboardPresentationTests: XCTestCase { ]) } - private func primaryAction( - _ workflow: MaintenanceWorkflow, - state: MaintenanceOperationState, - hasSelectedFsckTarget: Bool = true, - canRepairXattrs: Bool = true - ) -> MaintenanceUserAction? { - MaintenanceActionPolicy.primaryAction(for: MaintenanceActionContext( - workflow: workflow, - state: state, - hasSelectedFsckTarget: hasSelectedFsckTarget, - canRepairXattrs: canRepairXattrs - )) - } - private func doctorCheckWithoutDomain(status: String, message: String) -> JSONValue { .object([ "status": .string(status), From fcb6156cb1b07ffe10d64c886275cf7be3ee0298 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 27 May 2026 05:28:32 -0700 Subject: [PATCH 055/129] Treat passed GUI checkups as healthy/deployed --- .../Policies/DeviceStatusPolicy.swift | 3 -- .../Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../Workflows/DeviceDashboardSession.swift | 29 +++++++++++++++- .../DashboardStoreTests.swift | 34 ++++++++++++++++++- .../DeviceStatusPolicyTests.swift | 4 +-- 6 files changed, 65 insertions(+), 7 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift index 381cbc4a..93cf1f3c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift @@ -130,9 +130,6 @@ enum DeviceStatusPolicy { if checkup.warnCount > 0 || checkup.state == .warning { return .warning } - if profile.lastDeploy == nil { - return .readyToInstall - } return .healthy } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 2c4851cc..19c853b9 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -573,6 +573,7 @@ "status.unsupported" = "Unsupported"; "status.warning" = "Warning"; "summary.checkup_counts" = "PASS %d, WARN %d, FAIL %d"; +"summary.install_verified_by_checkup" = "Installed and verified by checkup."; "timeline.error.needs_attention" = "Needs Attention"; "timeline.error.needs_confirmation" = "Needs Confirmation"; "timeline.operation.activate" = "Activate"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings index 47e8a51d..6a34d307 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings @@ -573,6 +573,7 @@ "status.unsupported" = "不支持"; "status.warning" = "警告"; "summary.checkup_counts" = "PASS %d,WARN %d,FAIL %d"; +"summary.install_verified_by_checkup" = "已安装,并已通过 checkup 验证。"; "timeline.error.needs_attention" = "需要注意"; "timeline.error.needs_confirmation" = "需要确认"; "timeline.operation.activate" = "Activate"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift index d09f75c4..3fb0b32b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -465,16 +465,43 @@ final class DeviceDashboardSession: ObservableObject, Identifiable { let summary = doctorStore.summary else { return } + let observedAt = Date() Task { await appStore.deviceRegistry.updateCheckup(DeviceCheckupSnapshot( - checkedAt: Date(), + checkedAt: observedAt, state: state, passCount: summary.passCount, warnCount: summary.warnCount, failCount: summary.failCount, summary: L10n.format("summary.checkup_counts", summary.passCount, summary.warnCount, summary.failCount) ), for: profileID) + if let snapshot = verifiedDeploySnapshotFromPassedCheckup(profileID: profileID, state: state, observedAt: observedAt) { + await appStore.deviceRegistry.updateDeploy(snapshot, for: profileID) + } + } + } + + private func verifiedDeploySnapshotFromPassedCheckup( + profileID: DeviceProfile.ID, + state: DoctorWorkflowState, + observedAt: Date + ) -> DeviceDeploySnapshot? { + guard state == .passed, + !doctorStore.skipSSH, + let profile = appStore.deviceRegistry.profile(id: profileID) else { + return nil + } + if profile.lastDeploy?.verified == true { + return nil } + return DeviceDeploySnapshot( + deployedAt: profile.lastDeploy?.deployedAt ?? observedAt, + state: .deployed, + payloadFamily: profile.lastDeploy?.payloadFamily ?? profile.payloadFamily, + rebootRequested: profile.lastDeploy?.rebootRequested, + verified: true, + summary: profile.lastDeploy?.summary ?? L10n.string("summary.install_verified_by_checkup") + ) } private func updateDeploySnapshot(state: DeployWorkflowState) { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index cf7c2273..8d240b8e 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -33,7 +33,7 @@ final class DashboardStoreTests: XCTestCase { summary: "healthy" ), for: profile.id) let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) - XCTAssertEqual(fixture.appStore.dashboardSummary(for: checked).primaryAction, .installSMB) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: checked).primaryAction, .openSMB) await fixture.registry.updateDeploy(DeviceDeploySnapshot( deployedAt: Date(timeIntervalSince1970: 110), @@ -568,9 +568,41 @@ final class DashboardStoreTests: XCTestCase { try await waitUntilStoreState { session.doctorStore.state == .passed && fixture.registry.profile(id: first.id)?.lastCheckup?.state == .passed + && fixture.registry.profile(id: first.id)?.lastDeploy?.state == .deployed } XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastCheckup?.state, .passed) + XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastDeploy?.verified, true) + XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastDeploy?.summary, "Installed and verified by checkup.") XCTAssertNil(fixture.registry.profile(id: second.id)?.lastCheckup) + XCTAssertNil(fixture.registry.profile(id: second.id)?.lastDeploy) + } + + func testSkippedSSHCheckupDoesNotMarkRuntimeInstalled() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "local checks passed", domain: "General") + ])) + ], delayNanoseconds: 100_000_000) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) + session.doctorStore.skipSSH = true + + session.runCheckup(profile: profile) + + try await waitUntilStoreState { + session.doctorStore.state == .passed + && fixture.registry.profile(id: profile.id)?.lastCheckup?.state == .passed + } + XCTAssertNil(fixture.registry.profile(id: profile.id)?.lastDeploy) } func testDeploySnapshotUsesStartedProfileWhenSelectionChanges() async throws { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift index 62f8df8d..c36a9439 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift @@ -87,7 +87,7 @@ final class DeviceStatusPolicyTests: XCTestCase { XCTAssertEqual(status(try makeProfile(), .available), .unchecked) XCTAssertEqual(status(try makeProfile(lastDeploy: deployed()), .available), .healthy) XCTAssertEqual(status(try makeProfile(lastDeploy: deployed(verified: false)), .available), .warning) - XCTAssertEqual(status(try makeProfile(lastCheckup: passedCheckup()), .available), .readyToInstall) + XCTAssertEqual(status(try makeProfile(lastCheckup: passedCheckup()), .available), .healthy) 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) @@ -118,7 +118,7 @@ final class DeviceStatusPolicyTests: XCTestCase { for: try makeProfile(lastCheckup: passedCheckup()), passwordState: .available, activeOperation: nil - ), .installSMB) + ), .openSMB) XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( for: try makeProfile(lastCheckup: passedCheckup(), lastDeploy: deployed()), passwordState: .available, From 0bca0fecf2d95b1d46edd59f33fa16e9e89e24e5 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 27 May 2026 05:28:41 -0700 Subject: [PATCH 056/129] Speed up packaging --- macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg | Bin 515007 -> 581371 bytes macos/TimeCapsuleSMB/tools/package_app.py | 612 +++++++++++++++++--- tests/test_macos_package_app.py | 268 ++++++++- 3 files changed, 814 insertions(+), 66 deletions(-) diff --git a/macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg b/macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg index 43368e80c4b80c3abce806ffcc4b4bf7c59f3953..8073ccfe7189ef378f5cdc5e16639a9346467a97 100644 GIT binary patch literal 581371 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