feat: add mac node screen recording and ssh tunnel

This commit is contained in:
Peter Steinberger
2025-12-19 02:33:43 +01:00
parent 1fbd84da39
commit 95ea67de28
8 changed files with 311 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ final class MacNodeModeCoordinator {
private var task: Task<Void, Never>?
private let runtime = MacNodeRuntime()
private let session = MacNodeBridgeSession()
private var tunnel: RemotePortTunnel?
func start() {
guard self.task == nil else { return }
@@ -23,17 +24,30 @@ final class MacNodeModeCoordinator {
self.task?.cancel()
self.task = nil
Task { await self.session.disconnect() }
self.tunnel?.terminate()
self.tunnel = nil
}
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool? = nil
let defaults = UserDefaults.standard
while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
guard let endpoint = await Self.discoverBridgeEndpoint(timeoutSeconds: 5) else {
let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false
if lastCameraEnabled == nil {
lastCameraEnabled = cameraEnabled
} else if lastCameraEnabled != cameraEnabled {
lastCameraEnabled = cameraEnabled
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
continue
@@ -101,6 +115,7 @@ final class MacNodeModeCoordinator {
ClawdisCanvasA2UICommand.push.rawValue,
ClawdisCanvasA2UICommand.pushJSONL.rawValue,
ClawdisCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.record.rawValue,
]
let capsSet = Set(caps)
@@ -138,6 +153,30 @@ final class MacNodeModeCoordinator {
"mac-\(InstanceIdentity.instanceId)"
}
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
if mode == .remote {
do {
if self.tunnel == nil || self.tunnel?.process.isRunning == false {
self.tunnel = try await RemotePortTunnel.create(remotePort: 18790)
}
if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort)
{
return .hostPort(host: "127.0.0.1", port: port)
}
} catch {
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
self.tunnel?.terminate()
self.tunnel = nil
}
} else if let tunnel = self.tunnel {
tunnel.terminate()
self.tunnel = nil
}
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
}
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
final class DiscoveryState: @unchecked Sendable {
let lock = NSLock()

View File

@@ -5,6 +5,7 @@ import Foundation
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
@MainActor private let screenRecorder = ScreenRecordService()
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
@@ -115,6 +116,31 @@ actor MacNodeRuntime {
hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case MacNodeScreenCommand.record.rawValue:
let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ??
MacNodeScreenRecordParams()
let path = try await self.screenRecorder.record(
screenIndex: params.screenIndex,
durationMs: params.durationMs,
fps: params.fps,
outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
struct ScreenPayload: Encodable {
var format: String
var base64: String
var durationMs: Int?
var fps: Double?
var screenIndex: Int?
}
let payload = try Self.encodePayload(ScreenPayload(
format: params.format ?? "mp4",
base64: data.base64EncodedString(),
durationMs: params.durationMs,
fps: params.fps,
screenIndex: params.screenIndex))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
}

View File

@@ -0,0 +1,12 @@
import Foundation
enum MacNodeScreenCommand: String, Codable, Sendable {
case record = "screen.record"
}
struct MacNodeScreenRecordParams: Codable, Sendable, Equatable {
var screenIndex: Int?
var durationMs: Int?
var fps: Double?
var format: String?
}