@braedonsaunders/appkit-desk 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +83 -1
  2. package/backend.d.ts +28 -7
  3. package/backend.d.ts.map +1 -1
  4. package/backend.js +18 -521
  5. package/backend.js.map +1 -1
  6. package/events.d.ts +3 -0
  7. package/events.d.ts.map +1 -1
  8. package/events.js.map +1 -1
  9. package/host.d.ts +32 -1
  10. package/host.d.ts.map +1 -1
  11. package/host.js +141 -23
  12. package/host.js.map +1 -1
  13. package/index.d.ts +3 -0
  14. package/index.d.ts.map +1 -1
  15. package/index.js +3 -0
  16. package/index.js.map +1 -1
  17. package/machine.d.ts +145 -0
  18. package/machine.d.ts.map +1 -0
  19. package/machine.js +518 -0
  20. package/machine.js.map +1 -0
  21. package/network.d.ts +37 -0
  22. package/network.d.ts.map +1 -0
  23. package/network.js +173 -0
  24. package/network.js.map +1 -0
  25. package/package.json +2 -2
  26. package/plan.d.ts +129 -31
  27. package/plan.d.ts.map +1 -1
  28. package/plan.js +165 -9
  29. package/plan.js.map +1 -1
  30. package/port-relay.d.ts +13 -0
  31. package/port-relay.d.ts.map +1 -0
  32. package/port-relay.js +56 -0
  33. package/port-relay.js.map +1 -0
  34. package/virtualization.d.ts +16 -0
  35. package/virtualization.d.ts.map +1 -0
  36. package/virtualization.js +148 -0
  37. package/virtualization.js.map +1 -0
  38. package/vz/Package.swift +23 -0
  39. package/vz/Sources/DeskNet/Addresses.swift +106 -0
  40. package/vz/Sources/DeskNet/DHCP.swift +111 -0
  41. package/vz/Sources/DeskNet/Fence.swift +57 -0
  42. package/vz/Sources/DeskNet/NetworkStack.swift +547 -0
  43. package/vz/Sources/DeskNet/Packets.swift +355 -0
  44. package/vz/Sources/DeskNet/Plumbing.swift +312 -0
  45. package/vz/Sources/DeskNet/Sockets.swift +221 -0
  46. package/vz/Sources/DeskNet/TCPFlow.swift +448 -0
  47. package/vz/Sources/appkit-desk-vz/Control.swift +152 -0
  48. package/vz/Sources/appkit-desk-vz/Helper.swift +505 -0
  49. package/vz/Sources/appkit-desk-vz/main.swift +38 -0
  50. package/vz/Tests/DeskNetTests/DeskNetTests.swift +426 -0
  51. package/vz/appkit-desk-vz.entitlements +8 -0
  52. package/vz/build.sh +23 -0
  53. package/vz/check/extract-boot.py +221 -0
@@ -0,0 +1,152 @@
1
+ import Foundation
2
+
3
+ /// The control channel: newline-delimited JSON on stdin and stdout.
4
+ ///
5
+ /// Every request carries an `id` and an `op`, and is answered by exactly one
6
+ /// response with the same `id` — `{"id","ok":true,"result":{…}}` or
7
+ /// `{"id","ok":false,"error":"…"}`. Unsolicited events carry `event` and no
8
+ /// `id`. Diagnostics go to stderr, never stdout, so stdout is always parseable.
9
+ @MainActor
10
+ final class ControlChannel {
11
+ private var buffer = Data()
12
+ private let input: DispatchSourceRead
13
+ var onRequest: ((_ id: String, _ op: String, _ body: [String: Any]) -> Void)?
14
+ var onClose: (() -> Void)?
15
+
16
+ static let lineLimit = 1 << 20
17
+
18
+ init() {
19
+ let flags = fcntl(STDIN_FILENO, F_GETFL)
20
+ _ = fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK)
21
+ input = DispatchSource.makeReadSource(fileDescriptor: STDIN_FILENO, queue: .main)
22
+ input.setEventHandler { @Sendable [weak self] in Task { @MainActor in self?.readable() } }
23
+ }
24
+
25
+ func start() { input.resume() }
26
+
27
+ private func readable() {
28
+ var chunk = [UInt8](repeating: 0, count: 64 * 1024)
29
+ while true {
30
+ let count = chunk.withUnsafeMutableBytes { read(STDIN_FILENO, $0.baseAddress, $0.count) }
31
+ if count > 0 {
32
+ buffer.append(contentsOf: chunk[0..<count])
33
+ drainLines()
34
+ continue
35
+ }
36
+ if count == 0 {
37
+ // The host is gone. A helper that outlived it would leave a VM nobody
38
+ // can reach running on the Mac, so end of input is a stop.
39
+ input.cancel()
40
+ onClose?()
41
+ return
42
+ }
43
+ if errno == EAGAIN || errno == EINTR { return }
44
+ input.cancel()
45
+ onClose?()
46
+ return
47
+ }
48
+ }
49
+
50
+ private func drainLines() {
51
+ while let newline = buffer.firstIndex(of: 0x0a) {
52
+ let line = buffer[buffer.startIndex..<newline]
53
+ buffer.removeSubrange(buffer.startIndex...newline)
54
+ handle(Data(line))
55
+ }
56
+ if buffer.count > Self.lineLimit {
57
+ log("control line exceeded \(Self.lineLimit) bytes; discarding it")
58
+ buffer.removeAll()
59
+ }
60
+ }
61
+
62
+ private func handle(_ line: Data) {
63
+ if line.allSatisfy({ $0 == 0x20 || $0 == 0x0d || $0 == 0x09 }) { return }
64
+ guard let object = try? JSONSerialization.jsonObject(with: line), let body = object as? [String: Any] else {
65
+ log("ignoring a control line that is not a JSON object")
66
+ return
67
+ }
68
+ guard let id = body["id"] as? String, !id.isEmpty else {
69
+ log("ignoring a control request without an id")
70
+ return
71
+ }
72
+ guard let op = body["op"] as? String else {
73
+ respond(id, error: "Request has no op.")
74
+ return
75
+ }
76
+ onRequest?(id, op, body)
77
+ }
78
+
79
+ func respond(_ id: String, result: [String: Any]) {
80
+ write(["id": id, "ok": true, "result": result])
81
+ }
82
+
83
+ func respond(_ id: String, error: String) {
84
+ write(["id": id, "ok": false, "error": error])
85
+ }
86
+
87
+ func emit(_ event: String, _ fields: [String: Any] = [:]) {
88
+ var message = fields
89
+ message["event"] = event
90
+ write(message)
91
+ }
92
+
93
+ private func write(_ message: [String: Any]) {
94
+ guard var data = try? JSONSerialization.data(withJSONObject: message, options: [.sortedKeys]) else { return }
95
+ data.append(0x0a)
96
+ FileHandle.standardOutput.write(data)
97
+ }
98
+ }
99
+
100
+ func log(_ message: String) {
101
+ FileHandle.standardError.write(Data("appkit-desk-vz: \(message)\n".utf8))
102
+ }
103
+
104
+ /// A malformed or impossible request, reported back as the response's error.
105
+ struct RequestError: Error, CustomStringConvertible {
106
+ let description: String
107
+ init(_ description: String) { self.description = description }
108
+ }
109
+
110
+ extension Dictionary where Key == String, Value == Any {
111
+ func string(_ key: String) throws -> String {
112
+ guard let value = self[key] as? String, !value.isEmpty else {
113
+ throw RequestError("\(key) must be a non-empty string.")
114
+ }
115
+ return value
116
+ }
117
+
118
+ func optionalString(_ key: String) throws -> String? {
119
+ guard let value = self[key], !(value is NSNull) else { return nil }
120
+ guard let text = value as? String, !text.isEmpty else {
121
+ throw RequestError("\(key) must be a non-empty string when present.")
122
+ }
123
+ return text
124
+ }
125
+
126
+ func path(_ key: String) throws -> String {
127
+ let value = try string(key)
128
+ guard value.hasPrefix("/") else { throw RequestError("\(key) must be an absolute path.") }
129
+ return value
130
+ }
131
+
132
+ func optionalPath(_ key: String) throws -> String? {
133
+ guard let value = try optionalString(key) else { return nil }
134
+ guard value.hasPrefix("/") else { throw RequestError("\(key) must be an absolute path.") }
135
+ return value
136
+ }
137
+
138
+ func positiveInteger(_ key: String) throws -> Int {
139
+ guard let number = self[key] as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(),
140
+ number.doubleValue == number.doubleValue.rounded(), number.intValue > 0
141
+ else { throw RequestError("\(key) must be a positive integer.") }
142
+ return number.intValue
143
+ }
144
+
145
+ func bool(_ key: String, default fallback: Bool) throws -> Bool {
146
+ guard let value = self[key], !(value is NSNull) else { return fallback }
147
+ guard let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else {
148
+ throw RequestError("\(key) must be a boolean.")
149
+ }
150
+ return number.boolValue
151
+ }
152
+ }
@@ -0,0 +1,505 @@
1
+ import DeskNet
2
+ import Foundation
3
+ import Virtualization
4
+
5
+ /// Everything `boot` needs, validated before anything is created.
6
+ struct BootRequest {
7
+ let kernel: URL
8
+ let initrd: URL?
9
+ let cmdline: String
10
+ let disk: URL
11
+ let memoryMb: Int
12
+ let vcpus: Int
13
+ let vsockSocket: String
14
+ let portSocket: String?
15
+ let restoreFrom: URL?
16
+ let machineIdentifier: Data?
17
+ let consoleLog: URL?
18
+ let network: NetworkConfiguration
19
+
20
+ init(_ body: [String: Any]) throws {
21
+ kernel = URL(fileURLWithPath: try body.path("kernel"))
22
+ initrd = try body.optionalPath("initrd").map { URL(fileURLWithPath: $0) }
23
+ cmdline = (body["cmdline"] as? String) ?? ""
24
+ disk = URL(fileURLWithPath: try body.path("disk"))
25
+ memoryMb = try body.positiveInteger("memoryMb")
26
+ vcpus = try body.positiveInteger("vcpus")
27
+ vsockSocket = try body.path("vsockSocket")
28
+ portSocket = try body.optionalPath("portSocket")
29
+ restoreFrom = try body.optionalPath("restoreFrom").map { URL(fileURLWithPath: $0) }
30
+ if let encoded = try body.optionalString("machineIdentifier") {
31
+ guard let data = Data(base64Encoded: encoded) else {
32
+ throw RequestError("machineIdentifier must be base64.")
33
+ }
34
+ machineIdentifier = data
35
+ } else {
36
+ machineIdentifier = nil
37
+ }
38
+ consoleLog = try body.optionalPath("consoleLog").map { URL(fileURLWithPath: $0) }
39
+ if restoreFrom != nil && machineIdentifier == nil {
40
+ throw RequestError("restoreFrom requires the machineIdentifier the machine was saved with.")
41
+ }
42
+
43
+ let network = (body["network"] as? [String: Any]) ?? [:]
44
+ var configuration = NetworkConfiguration(allowLan: try network.bool("allowLan", default: false))
45
+ if let text = try network.optionalString("macAddress") {
46
+ guard let mac = MAC(text), !mac.isMulticast else { throw RequestError("network.macAddress is not a unicast MAC address.") }
47
+ configuration.guestMAC = mac
48
+ }
49
+ if let text = try network.optionalString("guestAddress") {
50
+ guard let address = IPv4(text) else { throw RequestError("network.guestAddress is not an IPv4 address.") }
51
+ configuration.guestAddress = address
52
+ }
53
+ if let text = try network.optionalString("gatewayAddress") {
54
+ guard let address = IPv4(text) else { throw RequestError("network.gatewayAddress is not an IPv4 address.") }
55
+ configuration.gatewayAddress = address
56
+ }
57
+ if network["prefixLength"] != nil {
58
+ let prefix = try network.positiveInteger("prefixLength")
59
+ guard (8...30).contains(prefix) else { throw RequestError("network.prefixLength must be between 8 and 30.") }
60
+ configuration.prefixLength = prefix
61
+ }
62
+ if let list = network["dns"] {
63
+ guard let entries = list as? [String], !entries.isEmpty, entries.count <= 3 else {
64
+ throw RequestError("network.dns must list one to three IPv4 resolvers.")
65
+ }
66
+ configuration.dns = try entries.map { entry in
67
+ guard let address = IPv4(entry) else { throw RequestError("network.dns entry \(entry) is not an IPv4 address.") }
68
+ return address
69
+ }
70
+ }
71
+ let subnet = CIDR(configuration.gatewayAddress, configuration.prefixLength)
72
+ guard subnet.contains(configuration.guestAddress), configuration.guestAddress != configuration.gatewayAddress else {
73
+ throw RequestError("network.guestAddress must be a different address in the gateway's subnet.")
74
+ }
75
+ self.network = configuration
76
+ }
77
+ }
78
+
79
+ /// One helper process runs one VM. It owns the VM, the guest's network, and
80
+ /// the two Unix sockets the host reaches the guest through.
81
+ @MainActor
82
+ final class Helper: NSObject, VZVirtualMachineDelegate {
83
+ private let control: ControlChannel
84
+ private var machine: VZVirtualMachine?
85
+ private var stack: NetworkStack?
86
+ private var link: DatagramLink?
87
+ private var vsockListener: HandshakeListener?
88
+ private var portListener: HandshakeListener?
89
+ private var relays: [ObjectIdentifier: Relay] = [:]
90
+ private var stateObservation: NSKeyValueObservation?
91
+ private var guestAddress: String?
92
+ private var machineIdentifier: Data?
93
+ private var booting = false
94
+ private var exiting = false
95
+
96
+ init(control: ControlChannel) {
97
+ self.control = control
98
+ super.init()
99
+ }
100
+
101
+ func handle(id: String, op: String, body: [String: Any]) {
102
+ switch op {
103
+ case "boot": boot(id, body)
104
+ case "pause": pause(id)
105
+ case "resume": resume(id)
106
+ case "save": save(id, body)
107
+ case "stop": stop(id)
108
+ case "status": control.respond(id, result: status())
109
+ default: control.respond(id, error: "Unknown op \(op).")
110
+ }
111
+ }
112
+
113
+ // MARK: Requests
114
+
115
+ private func boot(_ id: String, _ body: [String: Any]) {
116
+ guard machine == nil, !booting else {
117
+ return control.respond(id, error: "This helper has already booted a machine; one helper runs one VM.")
118
+ }
119
+ let request: BootRequest
120
+ let configuration: VZVirtualMachineConfiguration
121
+ do {
122
+ request = try BootRequest(body)
123
+ configuration = try makeConfiguration(request)
124
+ try configuration.validate()
125
+ if request.restoreFrom != nil { try configuration.validateSaveRestoreSupport() }
126
+ } catch {
127
+ return control.respond(id, error: describe(error))
128
+ }
129
+ booting = true
130
+ let vm = VZVirtualMachine(configuration: configuration, queue: .main)
131
+ vm.delegate = self
132
+ machine = vm
133
+ stateObservation = vm.observe(\.state, options: [.new]) { [weak self] _, _ in
134
+ Task { @MainActor [weak self] in
135
+ guard let self, let state = self.machine?.state else { return }
136
+ self.control.emit("state", ["state": Self.name(state)])
137
+ }
138
+ }
139
+ do {
140
+ try startListeners(request, vm: vm)
141
+ } catch {
142
+ teardown()
143
+ machine = nil
144
+ booting = false
145
+ return control.respond(id, error: describe(error))
146
+ }
147
+ let finish: @MainActor (Error?) -> Void = { [weak self] error in
148
+ guard let self else { return }
149
+ self.booting = false
150
+ if let error {
151
+ self.teardown()
152
+ self.machine = nil
153
+ self.control.respond(id, error: self.describe(error))
154
+ return
155
+ }
156
+ var result = self.status()
157
+ result["saveRestore"] = (try? configuration.validateSaveRestoreSupport()) != nil
158
+ self.control.respond(id, result: result)
159
+ }
160
+ if let restoreFrom = request.restoreFrom {
161
+ // Restored PAUSED: the host destroys the snapshot it came from, then
162
+ // sends `resume`. Until then the guest cannot write to its disk.
163
+ vm.restoreMachineStateFrom(url: restoreFrom) { error in
164
+ Task { @MainActor in finish(error) }
165
+ }
166
+ } else {
167
+ vm.start { result in
168
+ Task { @MainActor in
169
+ switch result {
170
+ case .success: finish(nil)
171
+ case .failure(let error): finish(error)
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ private func pause(_ id: String) {
179
+ guard let vm = machine, vm.canPause else { return control.respond(id, error: notNow("paused")) }
180
+ vm.pause { [weak self] result in
181
+ Task { @MainActor in self?.answer(id, result) }
182
+ }
183
+ }
184
+
185
+ private func resume(_ id: String) {
186
+ guard let vm = machine, vm.canResume else { return control.respond(id, error: notNow("resumed")) }
187
+ vm.resume { [weak self] result in
188
+ Task { @MainActor in self?.answer(id, result) }
189
+ }
190
+ }
191
+
192
+ /// Pause if running, then write the machine's memory and device state to
193
+ /// `path`. The VM is left paused; the host stops it next.
194
+ private func save(_ id: String, _ body: [String: Any]) {
195
+ let path: String
196
+ do {
197
+ path = try body.path("path")
198
+ } catch {
199
+ return control.respond(id, error: describe(error))
200
+ }
201
+ guard let vm = machine else { return control.respond(id, error: notNow("saved")) }
202
+ let url = URL(fileURLWithPath: path)
203
+ let write: @MainActor () -> Void = { [weak self] in
204
+ vm.saveMachineStateTo(url: url) { error in
205
+ Task { @MainActor in
206
+ guard let self else { return }
207
+ if let error { return self.control.respond(id, error: self.describe(error)) }
208
+ var result = self.status()
209
+ result["path"] = path
210
+ self.control.respond(id, result: result)
211
+ }
212
+ }
213
+ }
214
+ switch vm.state {
215
+ case .paused:
216
+ write()
217
+ case .running:
218
+ vm.pause { [weak self] result in
219
+ Task { @MainActor in
220
+ guard let self else { return }
221
+ if case .failure(let error) = result { return self.control.respond(id, error: self.describe(error)) }
222
+ write()
223
+ }
224
+ }
225
+ default:
226
+ control.respond(id, error: notNow("saved"))
227
+ }
228
+ }
229
+
230
+ /// Hard stop, like pulling the power: the host has synced the guest first.
231
+ /// The helper exits once the response is written.
232
+ func stop(_ id: String?) {
233
+ guard let vm = machine, vm.state != .stopped, vm.state != .error else {
234
+ if let id { control.respond(id, result: ["state": "stopped"]) }
235
+ return exit(code: 0)
236
+ }
237
+ let done: @MainActor (Error?) -> Void = { [weak self] error in
238
+ guard let self else { return }
239
+ if let id {
240
+ if let error {
241
+ self.control.respond(id, error: self.describe(error))
242
+ } else {
243
+ self.control.respond(id, result: ["state": "stopped"])
244
+ }
245
+ }
246
+ self.exit(code: error == nil ? 0 : 1)
247
+ }
248
+ if vm.canStop {
249
+ vm.stop { error in Task { @MainActor in done(error) } }
250
+ } else {
251
+ // Mid-transition (starting, saving, restoring): nothing to stop cleanly.
252
+ // Exiting the process ends the VM with it.
253
+ done(nil)
254
+ }
255
+ }
256
+
257
+ private func status() -> [String: Any] {
258
+ var result: [String: Any] = ["state": machine.map { Self.name($0.state) } ?? "stopped"]
259
+ if let stack {
260
+ result["guestAddress"] = stack.configuration.guestAddress.description
261
+ result["gatewayAddress"] = stack.configuration.gatewayAddress.description
262
+ }
263
+ result["guestAddressSeen"] = guestAddress != nil
264
+ if let machineIdentifier { result["machineIdentifier"] = machineIdentifier.base64EncodedString() }
265
+ return result
266
+ }
267
+
268
+ private func answer(_ id: String, _ result: Result<Void, Error>) {
269
+ switch result {
270
+ case .success: control.respond(id, result: status())
271
+ case .failure(let error): control.respond(id, error: describe(error))
272
+ }
273
+ }
274
+
275
+ private func notNow(_ verb: String) -> String {
276
+ let state = machine.map { Self.name($0.state) } ?? "not booted"
277
+ return "The machine cannot be \(verb) while it is \(state)."
278
+ }
279
+
280
+ // MARK: Configuration
281
+
282
+ private func makeConfiguration(_ request: BootRequest) throws -> VZVirtualMachineConfiguration {
283
+ let configuration = VZVirtualMachineConfiguration()
284
+ let loader = VZLinuxBootLoader(kernelURL: request.kernel)
285
+ loader.initialRamdiskURL = request.initrd
286
+ loader.commandLine = request.cmdline
287
+ configuration.bootLoader = loader
288
+
289
+ // A restored machine must be the same machine: the identifier is part of
290
+ // what the saved state is checked against.
291
+ let platform = VZGenericPlatformConfiguration()
292
+ if let data = request.machineIdentifier {
293
+ guard let identifier = VZGenericMachineIdentifier(dataRepresentation: data) else {
294
+ throw RequestError("machineIdentifier is not a valid machine identifier.")
295
+ }
296
+ platform.machineIdentifier = identifier
297
+ }
298
+ machineIdentifier = platform.machineIdentifier.dataRepresentation
299
+ configuration.platform = platform
300
+
301
+ let minimumCPUs = VZVirtualMachineConfiguration.minimumAllowedCPUCount
302
+ let maximumCPUs = VZVirtualMachineConfiguration.maximumAllowedCPUCount
303
+ guard (minimumCPUs...maximumCPUs).contains(request.vcpus) else {
304
+ throw RequestError("vcpus must be between \(minimumCPUs) and \(maximumCPUs) on this Mac.")
305
+ }
306
+ configuration.cpuCount = request.vcpus
307
+ let memory = UInt64(request.memoryMb) * 1024 * 1024
308
+ let minimumMemory = VZVirtualMachineConfiguration.minimumAllowedMemorySize
309
+ let maximumMemory = VZVirtualMachineConfiguration.maximumAllowedMemorySize
310
+ guard memory >= minimumMemory, memory <= maximumMemory else {
311
+ throw RequestError(
312
+ "memoryMb must be between \(minimumMemory / 1_048_576) and \(maximumMemory / 1_048_576) on this Mac.")
313
+ }
314
+ configuration.memorySize = memory
315
+
316
+ let disk = try VZDiskImageStorageDeviceAttachment(
317
+ url: request.disk, readOnly: false, cachingMode: .automatic, synchronizationMode: .full)
318
+ configuration.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: disk)]
319
+
320
+ let (helperEnd, vmEnd) = try DatagramLink.makePair()
321
+ let nic = VZVirtioNetworkDeviceConfiguration()
322
+ nic.attachment = VZFileHandleNetworkDeviceAttachment(
323
+ fileHandle: FileHandle(fileDescriptor: vmEnd, closeOnDealloc: true))
324
+ if let mac = request.network.guestMAC {
325
+ guard let address = VZMACAddress(string: mac.description) else {
326
+ throw RequestError("network.macAddress is not a valid MAC address.")
327
+ }
328
+ nic.macAddress = address
329
+ }
330
+ configuration.networkDevices = [nic]
331
+ var network = request.network
332
+ network.guestMAC = MAC(nic.macAddress.string) ?? network.guestMAC
333
+ attachNetwork(network, fd: helperEnd)
334
+
335
+ configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
336
+ configuration.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
337
+ configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
338
+
339
+ // Linux guests commonly use hvc0 as their primary console. Keep the
340
+ // device present even when its output is discarded.
341
+ let console = VZVirtioConsoleDeviceSerialPortConfiguration()
342
+ if let consoleLog = request.consoleLog {
343
+ console.attachment = try VZFileSerialPortAttachment(url: consoleLog, append: true)
344
+ } else {
345
+ console.attachment = VZFileHandleSerialPortAttachment(
346
+ fileHandleForReading: nil, fileHandleForWriting: try FileHandle(forWritingTo: URL(fileURLWithPath: "/dev/null")))
347
+ }
348
+ configuration.serialPorts = [console]
349
+ return configuration
350
+ }
351
+
352
+ private func attachNetwork(_ configuration: NetworkConfiguration, fd: Int32) {
353
+ let link = DatagramLink(fd: fd)
354
+ let stack = NetworkStack(configuration: configuration) { [weak link] frame in link?.send(frame) }
355
+ link.onFrame = { [weak stack] frame in stack?.receiveFrame(frame) }
356
+ stack.onGuestAddress = { [weak self] address in
357
+ guard let self, self.guestAddress == nil else { return }
358
+ self.guestAddress = address.description
359
+ self.control.emit("guest-ip", ["address": address.description])
360
+ }
361
+ self.link = link
362
+ self.stack = stack
363
+ }
364
+
365
+ // MARK: Sockets the host reaches the guest through
366
+
367
+ private func startListeners(_ request: BootRequest, vm: VZVirtualMachine) throws {
368
+ vsockListener = try HandshakeListener(path: request.vsockSocket) { [weak self] client, port, early in
369
+ self?.bridgeVsock(client: client, port: port, early: early)
370
+ }
371
+ if let portSocket = request.portSocket {
372
+ portListener = try HandshakeListener(path: portSocket) { [weak self] client, port, early in
373
+ guard let self, let stack = self.stack, port >= 1, port <= 65_535 else {
374
+ Darwin.close(client)
375
+ return
376
+ }
377
+ stack.connectToGuest(port: UInt16(port), clientFD: client, early: early)
378
+ }
379
+ }
380
+ }
381
+
382
+ /// Connect to a guest vsock port and join it to the client. A refused port
383
+ /// closes the client with nothing written, which is how Cloud Hypervisor's
384
+ /// hybrid vsock presents one; the host retries on that.
385
+ private func bridgeVsock(client: Int32, port: UInt32, early: [UInt8]) {
386
+ guard let vm = machine, vm.state == .running,
387
+ let device = vm.socketDevices.first as? VZVirtioSocketDevice
388
+ else {
389
+ Darwin.close(client)
390
+ return
391
+ }
392
+ device.connect(toPort: port) { [weak self] result in
393
+ Task { @MainActor in
394
+ guard let self else {
395
+ Darwin.close(client)
396
+ return
397
+ }
398
+ switch result {
399
+ case .failure(let error):
400
+ log("vsock connect to port \(port) failed: \(error.localizedDescription)")
401
+ Darwin.close(client)
402
+ case .success(let connection):
403
+ // Own a duplicate of the connection's descriptor so the relay's
404
+ // lifetime, not the connection object's, decides when it closes.
405
+ let guestFD = dup(connection.fileDescriptor)
406
+ guard guestFD >= 0 else {
407
+ connection.close()
408
+ Darwin.close(client)
409
+ return
410
+ }
411
+ let banner = Array("OK \(connection.sourcePort)\n".utf8)
412
+ let written = banner.withUnsafeBytes { Darwin.write(client, $0.baseAddress, banner.count) }
413
+ guard written == banner.count else {
414
+ connection.close()
415
+ Darwin.close(client)
416
+ Darwin.close(guestFD)
417
+ return
418
+ }
419
+ var key: ObjectIdentifier?
420
+ let relay = Relay(left: client, right: guestFD, early: early) { [weak self] in
421
+ connection.close()
422
+ if let key { self?.relays.removeValue(forKey: key) }
423
+ }
424
+ key = ObjectIdentifier(relay)
425
+ self.relays[ObjectIdentifier(relay)] = relay
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ // MARK: Delegate
432
+
433
+ nonisolated func guestDidStop(_ virtualMachine: VZVirtualMachine) {
434
+ Task { @MainActor in
435
+ control.emit("state", ["state": "stopped", "reason": "guest"])
436
+ exit(code: 0)
437
+ }
438
+ }
439
+
440
+ nonisolated func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
441
+ Task { @MainActor in
442
+ control.emit("state", ["state": "error", "error": describe(error)])
443
+ exit(code: 1)
444
+ }
445
+ }
446
+
447
+ nonisolated func virtualMachine(
448
+ _ virtualMachine: VZVirtualMachine, networkDevice: VZNetworkDevice,
449
+ attachmentWasDisconnectedWithError error: Error
450
+ ) {
451
+ Task { @MainActor in
452
+ control.emit("network-disconnected", ["error": describe(error)])
453
+ }
454
+ }
455
+
456
+ // MARK: Exit
457
+
458
+ private func teardown() {
459
+ for relay in Array(relays.values) { relay.close() }
460
+ relays.removeAll()
461
+ vsockListener?.close()
462
+ vsockListener = nil
463
+ portListener?.close()
464
+ portListener = nil
465
+ stack?.shutdown()
466
+ link?.close()
467
+ stateObservation?.invalidate()
468
+ stateObservation = nil
469
+ }
470
+
471
+ func exit(code: Int32) {
472
+ guard !exiting else { return }
473
+ exiting = true
474
+ teardown()
475
+ Foundation.exit(code)
476
+ }
477
+
478
+ func describe(_ error: Error) -> String {
479
+ if let error = error as? RequestError { return error.description }
480
+ if let error = error as? POSIXFailure { return error.description }
481
+ let ns = error as NSError
482
+ var text = ns.localizedDescription
483
+ if let reason = ns.localizedFailureReason, !text.contains(reason) { text += " \(reason)" }
484
+ if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError {
485
+ text += " (\(underlying.domain) \(underlying.code): \(underlying.localizedDescription))"
486
+ }
487
+ return "\(text) [\(ns.domain) \(ns.code)]"
488
+ }
489
+
490
+ static func name(_ state: VZVirtualMachine.State) -> String {
491
+ switch state {
492
+ case .stopped: "stopped"
493
+ case .running: "running"
494
+ case .paused: "paused"
495
+ case .error: "error"
496
+ case .starting: "starting"
497
+ case .pausing: "pausing"
498
+ case .resuming: "resuming"
499
+ case .stopping: "stopping"
500
+ case .saving: "saving"
501
+ case .restoring: "restoring"
502
+ @unknown default: "unknown"
503
+ }
504
+ }
505
+ }
@@ -0,0 +1,38 @@
1
+ import Foundation
2
+
3
+ /// appkit-desk-vz: one Linux microVM under Apple's Virtualization.framework,
4
+ /// driven over newline-delimited JSON on stdin and stdout. See README.md.
5
+
6
+ // A peer that hangs up must surface as EPIPE on that socket, not end the VM.
7
+ signal(SIGPIPE, SIG_IGN)
8
+
9
+ MainActor.assumeIsolated {
10
+ let control = ControlChannel()
11
+ let helper = Helper(control: control)
12
+ control.onRequest = { id, op, body in helper.handle(id: id, op: op, body: body) }
13
+ control.onClose = { helper.stop(nil) }
14
+
15
+ // SIGTERM and SIGINT stop the VM before the process goes, exactly as a
16
+ // `stop` request does, so a host that kills the helper never strands a VM.
17
+ var signalSources: [DispatchSourceSignal] = []
18
+ for number in [SIGTERM, SIGINT, SIGHUP] {
19
+ signal(number, SIG_IGN)
20
+ let source = DispatchSource.makeSignalSource(signal: number, queue: .main)
21
+ source.setEventHandler { @Sendable in Task { @MainActor in helper.stop(nil) } }
22
+ source.resume()
23
+ signalSources.append(source)
24
+ }
25
+ withExtendedLifetime(signalSources) {}
26
+ KeepAlive.objects = [control, helper, signalSources]
27
+
28
+ control.start()
29
+ control.emit("ready", ["protocol": 1, "pid": Int(getpid())])
30
+ }
31
+
32
+ /// Holds the long-lived objects for the life of the process.
33
+ @MainActor
34
+ enum KeepAlive {
35
+ static var objects: [Any] = []
36
+ }
37
+
38
+ dispatchMain()