From 90a3384238d5aca3a2e9e50d7a8335e11e0138f0 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 7 Jul 2026 20:04:27 +0300 Subject: [PATCH] feat(macos): wire remote-control status + machine registry management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS Machines screen was list/pairing-only and never surfaced remote-control availability, trust management, or the remoteDispatch actions the app-server already exposes. Wire the missing client surface: - AppServerAPI: remoteControl/status/read, remoteControl/enable|disable; machineRegistry/read|updateTrust|disable|forget; remoteDispatch/ negotiate|submit — with static camelCase param builders matching the Rust serde defs in app-server-protocol/v2. - BackendModels: RemoteControlStatusInfo, RemoteDispatchNegotiationInfo, RemoteDispatchSubmitInfo, MachineTrustState; MachineInfo.trustState. Submit status is kept verbatim so the camelCase `dryRun` value survives. - AppModel: remoteControlStatus state + loadRemoteControlStatus() (run from refreshAll and the Machines settings tab); setRemoteControlEnabled, updateMachineTrust/disableMachine/forgetMachine; and a remoteControl/status/changed notification handler that refreshes status and the fleet. All new paths degrade gracefully on older servers via the existing isUnsupportedMethodError handling. - MachinesView: remote-control availability/status banner with an enable/disable toggle, plus a per-machine context menu for trust / disable / forget (omitted for the local machine). Remote control is enabled at runtime through remoteControl/enable rather than a spawn-time `--remote-control` flag: the app-server always stands up its remote-control handle for stdio, and passing the hidden flag would both hard-crash older bundled builds (clap rejects unknown args at launch, killing the whole JSON-RPC connection) and force remote control on with no user opt-in. Adds BackendTests param-builder/decoder coverage for the new surface. Co-Authored-By: Claude Opus 4.8 --- .../Sources/CodeWith/App/AppModel.swift | 88 ++++++++- .../CodeWith/Backend/AppServerAPI.swift | 168 ++++++++++++++++++ .../CodeWith/Backend/AppServerClient.swift | 11 ++ .../CodeWith/Backend/BackendModels.swift | 82 ++++++++- .../CodeWith/Screens/MachinesView.swift | 87 +++++++++ .../Sources/CodeWith/Shell/AppShell.swift | 14 +- .../Tests/CodeWithTests/BackendTests.swift | 152 ++++++++++++++++ 7 files changed, 597 insertions(+), 5 deletions(-) diff --git a/macos/CodeWith/Sources/CodeWith/App/AppModel.swift b/macos/CodeWith/Sources/CodeWith/App/AppModel.swift index b7c72627e..f85dd2f3c 100644 --- a/macos/CodeWith/Sources/CodeWith/App/AppModel.swift +++ b/macos/CodeWith/Sources/CodeWith/App/AppModel.swift @@ -144,6 +144,7 @@ final class AppModel { var machinesError: String? = nil var selectedMachineId: String? = nil var machinePairing: MachinePairingInfo? = nil + var remoteControlStatus: RemoteControlStatusInfo? = nil var authProfiles: [AuthProfileInfo] = [] var profileError: String? = nil var accountUsage: AccountUsageInfo? = nil @@ -383,6 +384,7 @@ final class AppModel { async let profiles: () = loadProfiles() async let apps: () = loadApps() async let machines: () = loadMachines() + async let remoteControl: () = loadRemoteControlStatus() async let peers: () = loadActivePeers() async let agents: () = loadAgentRuns() async let requirements: () = loadConfigRequirements() @@ -390,7 +392,7 @@ final class AppModel { async let catalog: () = loadModelCatalog() await loadThreads(reset: true) // fast first-page paint await loadLoops() - _ = await (acct, apps, machines, peers, agents, profiles, requirements, catalog) + _ = await (acct, apps, machines, remoteControl, peers, agents, profiles, requirements, catalog) // Drain remaining pages in the background so Projects becomes complete. Task { @MainActor [weak self] in guard let self else { return } @@ -470,6 +472,83 @@ final class AppModel { return false } + /// Read remote-control availability/status from the app-server. Older builds + /// that don't expose `remoteControl/status/read` simply leave the status nil + /// (the Machines screen then hides the remote-control banner). + func loadRemoteControlStatus() async { + guard connection == .connected else { return } + do { + remoteControlStatus = try await client.readRemoteControlStatus() + } catch { + // Unsupported on this server build, or remote control unavailable + // (no state DB). Treat as "no remote control" rather than surfacing + // a raw JSON-RPC error on the fleet screen. + remoteControlStatus = nil + } + } + + /// Toggle remote control on the app-owned app-server, then refresh status. + func setRemoteControlEnabled(_ enabled: Bool) async { + guard connection == .connected else { return } + do { + let status = enabled + ? try await client.enableRemoteControl() + : try await client.disableRemoteControl() + remoteControlStatus = status + machinesError = nil + } catch { + // enable() may report remote control is unavailable (e.g. no state + // DB); reflect a short note instead of crashing. + machinesError = Self.isUnsupportedMethodError(error) + ? "this app-server version doesn't support remote control." + : error.localizedDescription + await loadRemoteControlStatus() + } + } + + /// Change a machine's trust state, then reload the fleet. + func updateMachineTrust(_ machine: MachineInfo, trustState: MachineTrustState) async { + guard connection == .connected else { return } + do { + _ = try await client.updateMachineTrust(machineId: machine.machineId, trustState: trustState) + machinesError = nil + await loadMachines() + } catch { + machinesError = Self.isUnsupportedMethodError(error) + ? "this app-server version doesn't support trust management." + : error.localizedDescription + } + } + + /// Disable a machine, then reload the fleet. + func disableMachine(_ machine: MachineInfo) async { + guard connection == .connected else { return } + do { + _ = try await client.disableMachine(machineId: machine.machineId) + machinesError = nil + await loadMachines() + } catch { + machinesError = Self.isUnsupportedMethodError(error) + ? "this app-server version doesn't support disabling machines." + : error.localizedDescription + } + } + + /// Forget a machine, then reload the fleet. + func forgetMachine(_ machine: MachineInfo) async { + guard connection == .connected else { return } + do { + _ = try await client.forgetMachine(machineId: machine.machineId) + machinesError = nil + if selectedMachineId == machine.machineId { selectedMachineId = nil } + await loadMachines() + } catch { + machinesError = Self.isUnsupportedMethodError(error) + ? "this app-server version doesn't support forgetting machines." + : error.localizedDescription + } + } + func startMachinePairing() async { guard connection == .connected else { return } machinesError = nil @@ -1246,6 +1325,7 @@ final class AppModel { await loadArchivedThreads() case "Machines": await loadMachines() + await loadRemoteControlStatus() default: break } @@ -1658,6 +1738,12 @@ final class AppModel { case "app/list/updated": let updated = AppServerClient.parseApps(params["data"]?.array ?? []) if updated.isEmpty { Task { await loadApps() } } else { apps = updated } + case "remoteControl/status/changed": + // The payload carries the same 4-field shape as status/read; apply it + // directly and refresh the fleet, since trust/registry state can shift + // when the remote-control connection changes. + remoteControlStatus = RemoteControlStatusInfo(from: params) + Task { await loadMachines() } case "serverRequest/resolved": let resolvedId = params["requestId"] ?? params["id"] let resolvedThreadId = params["threadId"]?.string diff --git a/macos/CodeWith/Sources/CodeWith/Backend/AppServerAPI.swift b/macos/CodeWith/Sources/CodeWith/Backend/AppServerAPI.swift index 01db1ec44..50aad8315 100644 --- a/macos/CodeWith/Sources/CodeWith/Backend/AppServerAPI.swift +++ b/macos/CodeWith/Sources/CodeWith/Backend/AppServerAPI.swift @@ -1137,6 +1137,174 @@ extension AppServerClient { return .object(["pairingCode": .string(pairing.pairingCode)]) } + // MARK: Remote control status + + /// Read the current remote-control connection status + remote identity. + /// (`remoteControl/status/read`, no params.) + func readRemoteControlStatus() async throws -> RemoteControlStatusInfo { + let r = try await request("remoteControl/status/read", .null, timeout: 20) + return RemoteControlStatusInfo(from: r) + } + + /// Enable remote control for the app-owned app-server. Returns the resulting + /// status; on builds/configs without a state DB the server reports it + /// unavailable rather than crashing. + func enableRemoteControl() async throws -> RemoteControlStatusInfo { + let r = try await request("remoteControl/enable", .null, timeout: 30) + return RemoteControlStatusInfo(from: r) + } + + func disableRemoteControl() async throws -> RemoteControlStatusInfo { + let r = try await request("remoteControl/disable", .null, timeout: 20) + return RemoteControlStatusInfo(from: r) + } + + // MARK: Machine registry management + + /// Read a single machine's registry entry (`machineRegistry/read`). + func readMachine(machineId: String) async throws -> MachineInfo? { + let r = try await request( + "machineRegistry/read", + Self.machineRegistryMachineIdParams(machineId: machineId), + timeout: 20) + guard let machine = r["machine"], !machine.isNull else { return nil } + return MachineInfo(registryValue: machine) + } + + /// Change a machine's trust state (`machineRegistry/updateTrust`). + @discardableResult + func updateMachineTrust(machineId: String, trustState: MachineTrustState) async throws -> MachineInfo? { + let r = try await request( + "machineRegistry/updateTrust", + Self.machineRegistryUpdateTrustParams(machineId: machineId, trustState: trustState), + timeout: 20) + guard let machine = r["machine"], !machine.isNull else { return nil } + return MachineInfo(registryValue: machine) + } + + /// Disable a machine (`machineRegistry/disable`). + @discardableResult + func disableMachine(machineId: String) async throws -> MachineInfo? { + let r = try await request( + "machineRegistry/disable", + Self.machineRegistryMachineIdParams(machineId: machineId), + timeout: 20) + guard let machine = r["machine"], !machine.isNull else { return nil } + return MachineInfo(registryValue: machine) + } + + /// Forget a machine (`machineRegistry/forget`). Returns whether it existed. + @discardableResult + func forgetMachine(machineId: String) async throws -> Bool { + let r = try await request( + "machineRegistry/forget", + Self.machineRegistryMachineIdParams(machineId: machineId), + timeout: 20) + return r["found"]?.bool ?? false + } + + static func machineRegistryMachineIdParams(machineId: String) -> JSONValue { + .object(["machineId": .string(machineId)]) + } + + static func machineRegistryUpdateTrustParams(machineId: String, trustState: MachineTrustState) -> JSONValue { + .object([ + "machineId": .string(machineId), + "trustState": .string(trustState.rawValue), + ]) + } + + // MARK: Remote dispatch + + /// Negotiate the remote-dispatch protocol/capabilities with a peer machine + /// (`remoteDispatch/negotiate`). + func negotiateRemoteDispatch( + sourceMachineId: String? = nil, + targetMachineId: String? = nil, + protocolVersion: Int? = nil, + requestedCapabilities: [String]? = nil + ) async throws -> RemoteDispatchNegotiationInfo { + let r = try await request( + "remoteDispatch/negotiate", + Self.remoteDispatchNegotiateParams( + sourceMachineId: sourceMachineId, + targetMachineId: targetMachineId, + protocolVersion: protocolVersion, + requestedCapabilities: requestedCapabilities), + timeout: 20) + return RemoteDispatchNegotiationInfo(from: r) + } + + /// Submit a remote-dispatch operation to a target machine + /// (`remoteDispatch/submit`). `operation` is a tagged `{type, params}` value. + func submitRemoteDispatch( + requestId: String, + sourceMachineId: String, + targetMachineId: String, + idempotencyKey: String, + operation: JSONValue, + requestedAt: Int? = nil, + expiresAt: Int? = nil, + capabilityVersion: String? = nil, + dryRun: Bool = false + ) async throws -> RemoteDispatchSubmitInfo { + let r = try await request( + "remoteDispatch/submit", + Self.remoteDispatchSubmitParams( + requestId: requestId, + sourceMachineId: sourceMachineId, + targetMachineId: targetMachineId, + idempotencyKey: idempotencyKey, + operation: operation, + requestedAt: requestedAt, + expiresAt: expiresAt, + capabilityVersion: capabilityVersion, + dryRun: dryRun), + timeout: 30) + return RemoteDispatchSubmitInfo(from: r) + } + + static func remoteDispatchNegotiateParams( + sourceMachineId: String? = nil, + targetMachineId: String? = nil, + protocolVersion: Int? = nil, + requestedCapabilities: [String]? = nil + ) -> JSONValue { + var params: [String: JSONValue] = [:] + if let sourceMachineId { params["sourceMachineId"] = .string(sourceMachineId) } + if let targetMachineId { params["targetMachineId"] = .string(targetMachineId) } + if let protocolVersion { params["protocolVersion"] = .number(Double(protocolVersion)) } + if let requestedCapabilities { + params["requestedCapabilities"] = .array(requestedCapabilities.map(JSONValue.string)) + } + return .object(params) + } + + static func remoteDispatchSubmitParams( + requestId: String, + sourceMachineId: String, + targetMachineId: String, + idempotencyKey: String, + operation: JSONValue, + requestedAt: Int? = nil, + expiresAt: Int? = nil, + capabilityVersion: String? = nil, + dryRun: Bool = false + ) -> JSONValue { + var params: [String: JSONValue] = [ + "requestId": .string(requestId), + "sourceMachineId": .string(sourceMachineId), + "targetMachineId": .string(targetMachineId), + "idempotencyKey": .string(idempotencyKey), + "operation": operation, + ] + if let requestedAt { params["requestedAt"] = .number(Double(requestedAt)) } + if let expiresAt { params["expiresAt"] = .number(Double(expiresAt)) } + if let capabilityVersion { params["capabilityVersion"] = .string(capabilityVersion) } + if dryRun { params["dryRun"] = .bool(true) } + return .object(params) + } + func listApps() async throws -> [AppItemInfo] { var out: [AppItemInfo] = [] var cursor: String? diff --git a/macos/CodeWith/Sources/CodeWith/Backend/AppServerClient.swift b/macos/CodeWith/Sources/CodeWith/Backend/AppServerClient.swift index 1e9c8d57a..7561f8e55 100644 --- a/macos/CodeWith/Sources/CodeWith/Backend/AppServerClient.swift +++ b/macos/CodeWith/Sources/CodeWith/Backend/AppServerClient.swift @@ -126,6 +126,17 @@ final class AppServerClient: @unchecked Sendable { let inPipe = Pipe() let outPipe = Pipe() proc.executableURL = URL(fileURLWithPath: bin) + // Deliberately NOT passing `--remote-control` here. The app-server always + // stands up its remote-control handle for the stdio transport, so remote + // control is turned on at runtime via the `remoteControl/enable` RPC (the + // Machines screen's Enable action) and reported by `remoteControl/status/read`. + // Two reasons to avoid the spawn-time flag: + // 1. Older bundled/installed builds predate the hidden flag and clap + // rejects unknown arguments hard, so the process would exit at launch + // — killing the entire JSON-RPC connection, not just remote control. + // 2. The flag would force remote control on by every launch with no user + // opt-in (enable/disable is in-memory only server-side), so an explicit + // Enable is both safer and durable-by-intent. proc.arguments = ["app-server"] proc.standardInput = inPipe proc.standardOutput = outPipe diff --git a/macos/CodeWith/Sources/CodeWith/Backend/BackendModels.swift b/macos/CodeWith/Sources/CodeWith/Backend/BackendModels.swift index ba6f1795c..d8578dcb3 100644 --- a/macos/CodeWith/Sources/CodeWith/Backend/BackendModels.swift +++ b/macos/CodeWith/Sources/CodeWith/Backend/BackendModels.swift @@ -181,15 +181,18 @@ struct MachineInfo: Identifiable, Hashable { var status: String // online / offline / unknown var role: String var isLocal: Bool + /// Raw machineRegistry trust state: local / trusted / untrusted / disabled / revoked. + var trustState: String var online: Bool { status == "online" } - init(id: String, os: String, status: String, role: String, isLocal: Bool) { + init(id: String, os: String, status: String, role: String, isLocal: Bool, trustState: String = "") { self.machineId = id self.displayName = id self.os = os self.status = status self.role = role self.isLocal = isLocal + self.trustState = trustState } init(registryValue v: JSONValue) { @@ -202,12 +205,87 @@ struct MachineInfo: Identifiable, Hashable { status = (v["healthState"]?.string ?? "unknown").lowercased() let source = v["sourceKind"]?.string ?? "" let trust = v["trustState"]?.string ?? "" + trustState = trust.lowercased() role = [source, trust].filter { !$0.isEmpty }.joined(separator: " · ") - isLocal = (v["trustState"]?.string ?? "").lowercased() == "local" + isLocal = trustState == "local" || (v["sourceKind"]?.string ?? "").lowercased() == "local" } } +/// Trust state accepted by `machineRegistry/updateTrust` (camelCase on the wire). +/// `.local` is reserved for the local machine and rejected by the server. +enum MachineTrustState: String, CaseIterable { + case local + case trusted + case untrusted + case disabled + case revoked +} + +/// Remote-control connection status + remote identity from the app-server. +/// Mirrors `RemoteControlStatusReadResponse` (all fields camelCase on the wire). +struct RemoteControlStatusInfo: Hashable { + /// disabled / connecting / connected / errored. + var status: String + var serverName: String + var installationId: String + var environmentId: String? + + init(status: String = "disabled", serverName: String = "", installationId: String = "", environmentId: String? = nil) { + self.status = status + self.serverName = serverName + self.installationId = installationId + self.environmentId = environmentId + } + + init(from v: JSONValue) { + status = (v["status"]?.string ?? "disabled").lowercased() + serverName = v["serverName"]?.string ?? "" + installationId = v["installationId"]?.string ?? "" + environmentId = v["environmentId"]?.string + } + + var isConnected: Bool { status == "connected" } + var isAvailable: Bool { status != "disabled" } +} + +/// Result of a `remoteDispatch/negotiate` call (capabilities the peer exposes). +struct RemoteDispatchNegotiationInfo: Hashable { + var protocolVersion: Int + var requiredTrustState: String + var supportedCapabilities: [String] + var supportedOperations: [String] + var deniedOperationClasses: [String] + var localMachineId: String? + + init(from v: JSONValue) { + protocolVersion = v["protocolVersion"]?.int ?? 0 + requiredTrustState = v["requiredTrustState"]?.string ?? "" + supportedCapabilities = (v["supportedCapabilities"]?.array ?? []).compactMap { $0.string } + supportedOperations = (v["supportedOperations"]?.array ?? []).compactMap { $0.string } + deniedOperationClasses = (v["deniedOperationClasses"]?.array ?? []).compactMap { $0.string } + localMachineId = v["localMachineId"]?.string + } +} + +/// Result of a `remoteDispatch/submit` call. +struct RemoteDispatchSubmitInfo: Equatable { + /// accepted / duplicate / denied / unsupported / failed / dryRun. + /// Kept verbatim from the wire — `dryRun` is camelCase, so do not lowercase. + var status: String + var requestId: String + /// The full receipt object, retained as raw JSON for callers that inspect it. + var receipt: JSONValue + var result: JSONValue? + + init(from v: JSONValue) { + status = v["status"]?.string ?? "" + requestId = v["requestId"]?.string ?? "" + receipt = v["receipt"] ?? .null + result = v["result"].flatMap { $0.isNull ? nil : $0 } + } +} + struct MachinePairingInfo: Hashable { var pairingCode: String var manualPairingCode: String? diff --git a/macos/CodeWith/Sources/CodeWith/Screens/MachinesView.swift b/macos/CodeWith/Sources/CodeWith/Screens/MachinesView.swift index 967cd63bd..d7c5b976f 100644 --- a/macos/CodeWith/Sources/CodeWith/Screens/MachinesView.swift +++ b/macos/CodeWith/Sources/CodeWith/Screens/MachinesView.swift @@ -5,8 +5,13 @@ struct MachinesView: View { var machines: [MachineInfo] = [] var error: String? = nil var pairing: MachinePairingInfo? = nil + var remoteControl: RemoteControlStatusInfo? = nil var onStartPairing: () -> Void = {} var onCheckPairing: () -> Void = {} + var onSetRemoteControlEnabled: (Bool) -> Void = { _ in } + var onUpdateTrust: (MachineInfo, MachineTrustState) -> Void = { _, _ in } + var onDisableMachine: (MachineInfo) -> Void = { _ in } + var onForgetMachine: (MachineInfo) -> Void = { _ in } @State private var showingAddMachine = false private let columns = [ @@ -27,6 +32,11 @@ struct MachinesView: View { Rectangle().fill(Theme.separator).frame(height: 1) ScrollColumn(spacing: 0) { + if let remoteControl { + remoteControlBanner(remoteControl) + .padding(.horizontal, 28) + .padding(.top, 20) + } if let error { Text("Machines unavailable: \(error)") .font(.system(size: 12)) @@ -156,6 +166,58 @@ struct MachinesView: View { .background(Theme.canvas) } + // Remote-control availability banner + toggle. + private func remoteControlBanner(_ rc: RemoteControlStatusInfo) -> some View { + let presentation = remoteControlPresentation(rc.status) + return HStack(spacing: 10) { + Circle().fill(presentation.color).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text("Remote control · \(presentation.label)") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textPrimary) + if !rc.serverName.isEmpty { + Text(rc.serverName) + .font(.system(size: 11)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + } + } + Spacer(minLength: 8) + Button { + onSetRemoteControlEnabled(!rc.isAvailable) + } label: { + Text(rc.isAvailable ? "Disable" : "Enable") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(rc.isAvailable ? Theme.textSecondary : Theme.accentForeground) + .padding(.horizontal, 12).frame(height: 26) + .background( + Capsule().fill(rc.isAvailable ? Theme.fieldFill : Theme.accent) + .overlay(Capsule().strokeBorder(Theme.cardStroke, lineWidth: rc.isAvailable ? 1 : 0)) + ) + } + .buttonStyle(.plain) + } + .padding(12) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: Theme.cardRadius, style: .continuous) + .fill(Theme.fieldFill) + .overlay( + RoundedRectangle(cornerRadius: Theme.cardRadius, style: .continuous) + .strokeBorder(Theme.cardStroke, lineWidth: 1) + ) + ) + } + + private func remoteControlPresentation(_ status: String) -> (label: String, color: Color) { + switch status.lowercased() { + case "connected": return ("connected", Theme.success) + case "connecting": return ("connecting", Theme.warning) + case "errored": return ("error", Theme.danger) + default: return ("disabled", Theme.textTertiary) + } + } + private func osLabel(_ os: String) -> String { let l = os.lowercased() if l.contains("mac") || l.contains("darwin") { return "macOS" } @@ -214,6 +276,31 @@ struct MachinesView: View { .strokeBorder(Theme.cardStroke, lineWidth: 1) ) ) + .contextMenu { machineMenu(m) } + } + + /// Trust / disable / forget controls. The local machine can't have its trust + /// reassigned, disabled, or forgotten, so those actions are omitted for it. + @ViewBuilder + private func machineMenu(_ m: MachineInfo) -> some View { + if !m.isLocal { + Menu("Set trust") { + ForEach(MachineTrustState.allCases.filter { $0 != .local }, id: \.rawValue) { state in + Button { + onUpdateTrust(m, state) + } label: { + if m.trustState == state.rawValue { + Label(state.rawValue.capitalized, systemImage: "checkmark") + } else { + Text(state.rawValue.capitalized) + } + } + } + } + Button("Disable") { onDisableMachine(m) } + Divider() + Button("Forget", role: .destructive) { onForgetMachine(m) } + } } private func statusPresentation(_ status: String) -> (icon: String, label: String, color: Color) { diff --git a/macos/CodeWith/Sources/CodeWith/Shell/AppShell.swift b/macos/CodeWith/Sources/CodeWith/Shell/AppShell.swift index 1e79732ed..ba8519b82 100644 --- a/macos/CodeWith/Sources/CodeWith/Shell/AppShell.swift +++ b/macos/CodeWith/Sources/CodeWith/Shell/AppShell.swift @@ -104,8 +104,13 @@ struct AppShell: View { machines: model.machines, error: model.machinesError, pairing: model.machinePairing, + remoteControl: model.remoteControlStatus, onStartPairing: { Task { await model.startMachinePairing() } }, - onCheckPairing: { Task { await model.refreshMachinePairingStatus() } }) + onCheckPairing: { Task { await model.refreshMachinePairingStatus() } }, + onSetRemoteControlEnabled: { enabled in Task { await model.setRemoteControlEnabled(enabled) } }, + onUpdateTrust: { machine, trust in Task { await model.updateMachineTrust(machine, trustState: trust) } }, + onDisableMachine: { machine in Task { await model.disableMachine(machine) } }, + onForgetMachine: { machine in Task { await model.forgetMachine(machine) } }) case .profiles: ProfilesView(profiles: model.authProfiles, activeEmail: model.account.email, profileError: model.profileError, @@ -214,8 +219,13 @@ struct AppShell: View { machines: model.machines, error: model.machinesError, pairing: model.machinePairing, + remoteControl: model.remoteControlStatus, onStartPairing: { Task { await model.startMachinePairing() } }, - onCheckPairing: { Task { await model.refreshMachinePairingStatus() } }) + onCheckPairing: { Task { await model.refreshMachinePairingStatus() } }, + onSetRemoteControlEnabled: { enabled in Task { await model.setRemoteControlEnabled(enabled) } }, + onUpdateTrust: { machine, trust in Task { await model.updateMachineTrust(machine, trustState: trust) } }, + onDisableMachine: { machine in Task { await model.disableMachine(machine) } }, + onForgetMachine: { machine in Task { await model.forgetMachine(machine) } }) default: SettingsGeneral( fullAccess: model.fullAccess, diff --git a/macos/CodeWith/Tests/CodeWithTests/BackendTests.swift b/macos/CodeWith/Tests/CodeWithTests/BackendTests.swift index 9abd2afdc..1727396aa 100644 --- a/macos/CodeWith/Tests/CodeWithTests/BackendTests.swift +++ b/macos/CodeWith/Tests/CodeWithTests/BackendTests.swift @@ -931,6 +931,158 @@ final class AppServerRequestShapeTests: XCTestCase { obj(["pairingCode": .string("pair-code")])) } + func testMachineRegistryMachineIdParams() { + XCTAssertEqual( + AppServerClient.machineRegistryMachineIdParams(machineId: "machine-1"), + obj(["machineId": .string("machine-1")])) + } + + func testMachineRegistryUpdateTrustParams() { + XCTAssertEqual( + AppServerClient.machineRegistryUpdateTrustParams(machineId: "machine-1", trustState: .trusted), + obj([ + "machineId": .string("machine-1"), + "trustState": .string("trusted"), + ])) + // The wire values must match the Rust `MachineRegistryTrustState` camelCase serde. + XCTAssertEqual(MachineTrustState.local.rawValue, "local") + XCTAssertEqual(MachineTrustState.trusted.rawValue, "trusted") + XCTAssertEqual(MachineTrustState.untrusted.rawValue, "untrusted") + XCTAssertEqual(MachineTrustState.disabled.rawValue, "disabled") + XCTAssertEqual(MachineTrustState.revoked.rawValue, "revoked") + } + + func testRemoteDispatchNegotiateParamsOmitsNilFields() { + XCTAssertEqual( + AppServerClient.remoteDispatchNegotiateParams(), + obj([:])) + XCTAssertEqual( + AppServerClient.remoteDispatchNegotiateParams( + sourceMachineId: "src", + targetMachineId: "dst", + protocolVersion: 2, + requestedCapabilities: ["durableMailboxEnqueue", "auditReceipts"]), + obj([ + "sourceMachineId": .string("src"), + "targetMachineId": .string("dst"), + "protocolVersion": .number(2), + "requestedCapabilities": .array([ + .string("durableMailboxEnqueue"), + .string("auditReceipts"), + ]), + ])) + } + + func testRemoteDispatchSubmitParams() { + let operation = obj([ + "type": .string("enqueueInstruction"), + "params": obj([ + "targetThreadId": .string("thread-9"), + "message": .string("go"), + ]), + ]) + XCTAssertEqual( + AppServerClient.remoteDispatchSubmitParams( + requestId: "req-1", + sourceMachineId: "src", + targetMachineId: "dst", + idempotencyKey: "idem-1", + operation: operation, + requestedAt: 100, + expiresAt: 200, + capabilityVersion: "1", + dryRun: true), + obj([ + "requestId": .string("req-1"), + "sourceMachineId": .string("src"), + "targetMachineId": .string("dst"), + "idempotencyKey": .string("idem-1"), + "operation": operation, + "requestedAt": .number(100), + "expiresAt": .number(200), + "capabilityVersion": .string("1"), + "dryRun": .bool(true), + ])) + } + + func testRemoteDispatchSubmitParamsOmitsDryRunAndOptionalFieldsWhenDefault() { + let operation = obj(["type": .string("mailboxReceipts"), "params": obj([:])]) + XCTAssertEqual( + AppServerClient.remoteDispatchSubmitParams( + requestId: "req-2", + sourceMachineId: "src", + targetMachineId: "dst", + idempotencyKey: "idem-2", + operation: operation), + obj([ + "requestId": .string("req-2"), + "sourceMachineId": .string("src"), + "targetMachineId": .string("dst"), + "idempotencyKey": .string("idem-2"), + "operation": operation, + ])) + } + + func testRemoteControlStatusInfoDecodesCamelCaseFields() { + let info = RemoteControlStatusInfo(from: obj([ + "status": .string("connected"), + "serverName": .string("spark01"), + "installationId": .string("install-123"), + "environmentId": .string("env-9"), + ])) + XCTAssertEqual(info.status, "connected") + XCTAssertEqual(info.serverName, "spark01") + XCTAssertEqual(info.installationId, "install-123") + XCTAssertEqual(info.environmentId, "env-9") + XCTAssertTrue(info.isConnected) + XCTAssertTrue(info.isAvailable) + + let disabled = RemoteControlStatusInfo(from: obj(["status": .string("disabled")])) + XCTAssertFalse(disabled.isConnected) + XCTAssertFalse(disabled.isAvailable) + XCTAssertNil(disabled.environmentId) + } + + func testRemoteDispatchNegotiationInfoDecodesArrays() { + let info = RemoteDispatchNegotiationInfo(from: obj([ + "protocolVersion": .number(1), + "requiredTrustState": .string("trusted"), + "supportedCapabilities": .array([.string("idempotency"), .string("auditReceipts")]), + "supportedOperations": .array([.string("enqueueInstruction")]), + "deniedOperationClasses": .array([.string("shell"), .string("config")]), + "localMachineId": .string("local-1"), + ])) + XCTAssertEqual(info.protocolVersion, 1) + XCTAssertEqual(info.requiredTrustState, "trusted") + XCTAssertEqual(info.supportedCapabilities, ["idempotency", "auditReceipts"]) + XCTAssertEqual(info.supportedOperations, ["enqueueInstruction"]) + XCTAssertEqual(info.deniedOperationClasses, ["shell", "config"]) + XCTAssertEqual(info.localMachineId, "local-1") + } + + func testRemoteDispatchSubmitInfoPreservesCamelCaseStatus() { + // `dryRun` is the only multi-word status; it must survive verbatim so + // callers can detect a dry-run receipt. + let info = RemoteDispatchSubmitInfo(from: obj([ + "status": .string("dryRun"), + "requestId": .string("req-1"), + "receipt": obj(["receiptId": .string("rcpt-1")]), + ])) + XCTAssertEqual(info.status, "dryRun") + XCTAssertEqual(info.requestId, "req-1") + XCTAssertEqual(info.receipt, obj(["receiptId": .string("rcpt-1")])) + XCTAssertNil(info.result) + + let accepted = RemoteDispatchSubmitInfo(from: obj([ + "status": .string("accepted"), + "requestId": .string("req-2"), + "receipt": .null, + "result": obj(["type": .string("mailboxReceipts")]), + ])) + XCTAssertEqual(accepted.status, "accepted") + XCTAssertEqual(accepted.result, obj(["type": .string("mailboxReceipts")])) + } + func testConfigWriteParamsForDesktopSettings() { XCTAssertEqual( AppServerClient.configWriteParams(keyPath: "desktop.showMenuBar", value: .bool(false)),