Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion macos/CodeWith/Sources/CodeWith/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -383,14 +384,15 @@ 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()
await loadConfig()
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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1246,6 +1325,7 @@ final class AppModel {
await loadArchivedThreads()
case "Machines":
await loadMachines()
await loadRemoteControlStatus()
default:
break
}
Expand Down Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions macos/CodeWith/Sources/CodeWith/Backend/AppServerAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
11 changes: 11 additions & 0 deletions macos/CodeWith/Sources/CodeWith/Backend/AppServerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading