Nucleic-Session: 28559516-7571-4295-A21C-28B0B95D9427 Co-authored-by: Nucleic <[email protected]>
297 lines
14 KiB
Swift
297 lines
14 KiB
Swift
import AppKit
|
|
import Darwin
|
|
import Foundation
|
|
import NucleicCore
|
|
import Virtualization
|
|
|
|
// Standalone de-risking spike for the macOS-VM rework (sibling of `container-spike`). NOT part of the
|
|
// app. Drives the REAL `MacVMEngine` end-to-end to prove the Virtualization-framework path on a signed
|
|
// build:
|
|
// • default mode — clone the golden base → boot the guest → `ssh` a command in it and capture stdout
|
|
// (the `mac_vm_exec` agent path) → teardown.
|
|
// • `NUCLEIC_MACVM_BUILD_BASE=1` — run the one-time base install instead (download/point-to the macOS
|
|
// restore image and install it into the base bundle). Long-running; then provision separately
|
|
// (scripts/provision-macos-guest.sh) before the default mode can SSH in.
|
|
//
|
|
// Build + run (must be signed with the virtualization entitlement, exactly like the app):
|
|
// swift build --product macvm-spike
|
|
// codesign --force --sign - --entitlements signing/spike.entitlements .build/.../macvm-spike
|
|
// # 1) build the base once (≈14 GB download + multi-minute install), then provision it:
|
|
// NUCLEIC_MACVM_BUILD_BASE=1 NUCLEIC_MACVM_IPSW=/path/to/UniversalMac.ipsw .build/.../macvm-spike
|
|
// # 2) with a provisioned base present, prove the clone/boot/exec/teardown path:
|
|
// .build/.../macvm-spike
|
|
|
|
func log(_ s: String) { FileHandle.standardError.write(Data((s + "\n").utf8)) }
|
|
func die(_ s: String) -> Never { log("✗ SPIKE FAIL: \(s)"); exit(1) }
|
|
|
|
func buildBase(_ engine: MacVMEngine) async throws {
|
|
log("[base] MacVMEngine.buildBaseImage — resolve restore image + install macOS …")
|
|
let ipsw = ProcessInfo.processInfo.environment["NUCLEIC_MACVM_IPSW"]
|
|
if let ipsw { log(" using local restore image: \(ipsw)") } else {
|
|
log(" no NUCLEIC_MACVM_IPSW set — fetching the latest supported restore image (~14 GB)")
|
|
}
|
|
try await engine.buildBaseImage(localRestoreImagePath: ipsw)
|
|
log("✓ base install complete. Now provision it (Setup Assistant + scripts/provision-macos-guest.sh)")
|
|
log(" before the default spike mode can SSH into a clone.")
|
|
}
|
|
|
|
func runRoundTrip(_ engine: MacVMEngine) async throws {
|
|
let spec = MacVMSpec(name: "nucleic-macvm-spike", mounts: [], workdir: nil)
|
|
|
|
log("[1/3] MacVMEngine.ensureRunning — clone base + boot + wait for SSH …")
|
|
let (name, ip) = try await engine.ensureRunning(spec)
|
|
log(" up: name=\(name) ip=\(ip)")
|
|
|
|
log("[2/3] MacVMEngine.run — ssh a command in the guest and capture stdout …")
|
|
let (code, out, err) = try await engine.run(
|
|
name: name, command: "echo macvm-stdio-ok; sw_vers -productVersion", workdir: nil)
|
|
log(" exit=\(code)")
|
|
log(" stdout=\(out)")
|
|
if !err.isEmpty { log(" stderr=\(err)") }
|
|
|
|
// Computer-use path: capture the VM screen (needs a computer-use-provisioned base — auto-login +
|
|
// Screen Recording TCC). Best-effort: reports the screenshot size, or why it came back empty.
|
|
log("[2b/3] MacVMEngine.performComputerAction(screenshot) — capture the VM screen …")
|
|
let (shot, shotSummary) = try await engine.performComputerAction(
|
|
name: name, action: "screenshot", x: nil, y: nil, text: nil,
|
|
scrollDirection: nil, scrollAmount: nil, durationMs: nil)
|
|
if let shot {
|
|
log(" screenshot ok: \(shot.count) base64 chars — \(shotSummary)")
|
|
} else {
|
|
log(" screenshot empty (expected unless the base is computer-use-provisioned): \(shotSummary)")
|
|
}
|
|
|
|
log("[3/3] MacVMEngine.remove (teardown) …")
|
|
_ = await engine.remove(name: name)
|
|
|
|
let ok = code == 0 && out.contains("macvm-stdio-ok")
|
|
log("")
|
|
log("─── RESULTS ───")
|
|
log(" clone + boot + ssh exec + stdout (full MacVMEngine path) : \(ok ? "PASS ✓" : "FAIL ✗")")
|
|
if ok {
|
|
log("✓ SPIKE PASS — the app's macOS-VM clone-and-run path works end-to-end on this machine.")
|
|
exit(0)
|
|
}
|
|
die("macOS-VM path check failed (see above)")
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
// Host-side virtual-IO spike (NUCLEIC_MACVM_SPIKE_HOSTIO=1)
|
|
//
|
|
// Proves the SIP-free computer-use path: capture the guest FRAMEBUFFER host-side via a
|
|
// `VZVirtualMachineView` and inject INPUT by delivering synthesized `NSEvent`s to that view — no
|
|
// in-guest agent, no TCC, no SIP. Boots a throwaway CLONE of the base (base stays pristine), lets it
|
|
// reach a visible screen, captures a PNG, injects a mouse move/click + keystrokes, captures again.
|
|
// Writes /tmp/nucleic-spike-hostio-{1,2}.png for inspection and logs whether the frames are non-blank.
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// The engine's storage layout, mirrored (the spike doesn't reach into the actor for paths).
|
|
func macvmStorageRoot() -> URL {
|
|
let base = (try? FileManager.default.url(
|
|
for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true))
|
|
?? URL(fileURLWithPath: NSTemporaryDirectory())
|
|
return base.appendingPathComponent("Nucleic/macvms", isDirectory: true)
|
|
}
|
|
|
|
/// Clone the base bundle to a temp dir (copy identity + aux, clonefile the disk) so the spike boots a
|
|
/// disposable copy and never dirties the golden base. Returns the clone bundle.
|
|
func cloneBaseForSpike() throws -> MacVMBundle {
|
|
let fm = FileManager.default
|
|
let base = MacVMBundle(root: macvmStorageRoot().appendingPathComponent("base", isDirectory: true))
|
|
guard base.isComplete else { die("no complete base bundle at \(base.root.path)") }
|
|
let dest = MacVMBundle(
|
|
root: fm.temporaryDirectory.appendingPathComponent(
|
|
"nucleic-spike-clone-\(UUID().uuidString)", isDirectory: true))
|
|
try fm.createDirectory(at: dest.root, withIntermediateDirectories: true)
|
|
try fm.copyItem(at: base.hardwareModelURL, to: dest.hardwareModelURL)
|
|
try fm.copyItem(at: base.machineIdentifierURL, to: dest.machineIdentifierURL)
|
|
try fm.copyItem(at: base.auxiliaryStorageURL, to: dest.auxiliaryStorageURL)
|
|
if clonefile(base.diskImageURL.path, dest.diskImageURL.path, 0) != 0 {
|
|
try fm.copyItem(at: base.diskImageURL, to: dest.diskImageURL) // non-APFS fallback
|
|
}
|
|
return dest
|
|
}
|
|
|
|
/// Drives the host-IO spike on the main thread under an AppKit run loop (VZVirtualMachineView is
|
|
/// main-thread + main-queue-VM only).
|
|
final class HostIOSpike: NSObject, VZVirtualMachineDelegate {
|
|
let cloneBundle: MacVMBundle
|
|
var vm: VZVirtualMachine!
|
|
var vmView: VZVirtualMachineView!
|
|
var window: NSWindow!
|
|
|
|
override init() {
|
|
do { cloneBundle = try cloneBaseForSpike() } catch { die("clone failed: \(error)") }
|
|
super.init()
|
|
}
|
|
|
|
func start() {
|
|
log("[hostio] cloned base → \(cloneBundle.root.lastPathComponent); building config …")
|
|
let config: VZVirtualMachineConfiguration
|
|
do {
|
|
config = try MacVMEngine.makeConfiguration(
|
|
bundle: cloneBundle, cpus: 4, memoryGiB: 8,
|
|
mac: VZMACAddress.randomLocallyAdministered().string, mounts: [])
|
|
} catch { die("makeConfiguration: \(error)") }
|
|
|
|
// On-screen window + view so both cacheDisplay and (fallback) window capture have something to
|
|
// render into. The VM is created on the MAIN queue — required for an interactive view.
|
|
let frame = NSRect(x: 0, y: 0, width: 1920, height: 1200)
|
|
vmView = VZVirtualMachineView(frame: frame)
|
|
window = NSWindow(
|
|
contentRect: frame, styleMask: [.titled, .closable, .resizable],
|
|
backing: .buffered, defer: false)
|
|
window.title = "macvm-spike host-IO"
|
|
window.contentView = vmView
|
|
window.setFrameOrigin(NSPoint(x: 80, y: 80))
|
|
window.makeKeyAndOrderFront(nil)
|
|
|
|
vm = VZVirtualMachine(configuration: config) // main-queue (default) VM
|
|
vm.delegate = self
|
|
vmView.virtualMachine = vm
|
|
vmView.capturesSystemKeys = true
|
|
|
|
log("[hostio] starting guest … (giving it ~45s to reach a visible screen)")
|
|
vm.start { result in
|
|
if case .failure(let e) = result { die("vm.start: \(e)") }
|
|
}
|
|
window.makeFirstResponder(vmView)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 45) { [self] in step1() }
|
|
}
|
|
|
|
func step1() {
|
|
log("[hostio] capture #1 (initial screen) …")
|
|
capture(label: "1")
|
|
log("[hostio] injecting input: move → click (center), then type 'hello' + return …")
|
|
injectInput()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [self] in step2() }
|
|
}
|
|
|
|
func step2() {
|
|
log("[hostio] capture #2 (after input) …")
|
|
capture(label: "2")
|
|
log("[hostio] stopping guest + cleaning up clone …")
|
|
let done: () -> Void = { [self] in
|
|
try? FileManager.default.removeItem(at: cloneBundle.root)
|
|
log("")
|
|
log("─── HOST-IO RESULTS ───")
|
|
log(" PNGs: /tmp/nucleic-spike-hostio-1.png (initial), -2.png (after input)")
|
|
log(" Inspect them: capture non-blank ⇒ host-side framebuffer capture works (no SIP).")
|
|
log(" a change between #1 and #2 ⇒ host-side input reaches the guest.")
|
|
exit(0)
|
|
}
|
|
if vm.canStop { vm.stop { _ in done() } } else { done() }
|
|
}
|
|
|
|
/// Try the two host-side capture paths and write whichever produce PNGs; report uniformity.
|
|
func capture(label: String) {
|
|
// (a) cacheDisplay into a bitmap rep (works for layer-backed views; may be blank for Metal).
|
|
if let rep = vmView.bitmapImageRepForCachingDisplay(in: vmView.bounds) {
|
|
vmView.cacheDisplay(in: vmView.bounds, to: rep)
|
|
writeRep(rep, path: "/tmp/nucleic-spike-hostio-\(label)-cachedisplay.png", how: "cacheDisplay")
|
|
}
|
|
// (b) CGWindowListCreateImage of our own window (needs it on-screen; may need host Screen
|
|
// Recording — a normal host grant, not guest SIP).
|
|
let wid = CGWindowID(window.windowNumber)
|
|
if let cg = CGWindowListCreateImage(
|
|
.null, .optionIncludingWindow, wid, [.boundsIgnoreFraming, .bestResolution])
|
|
{
|
|
let rep = NSBitmapImageRep(cgImage: cg)
|
|
writeRep(rep, path: "/tmp/nucleic-spike-hostio-\(label).png", how: "CGWindowList")
|
|
} else {
|
|
log(" CGWindowListCreateImage returned nil (host Screen Recording not granted?)")
|
|
}
|
|
}
|
|
|
|
func writeRep(_ rep: NSBitmapImageRep, path: String, how: String) {
|
|
guard let png = rep.representation(using: .png, properties: [:]) else {
|
|
log(" [\(how)] no PNG data"); return
|
|
}
|
|
try? png.write(to: URL(fileURLWithPath: path))
|
|
log(" [\(how)] wrote \(png.count) bytes → \(path) (\(uniformity(rep)))")
|
|
}
|
|
|
|
/// Cheap non-blank check: sample a grid and report how many distinct colors appear.
|
|
func uniformity(_ rep: NSBitmapImageRep) -> String {
|
|
var colors = Set<UInt32>()
|
|
let w = rep.pixelsWide, h = rep.pixelsHigh
|
|
guard w > 0, h > 0 else { return "empty" }
|
|
for gy in 0..<16 {
|
|
for gx in 0..<16 {
|
|
let x = gx * w / 16, y = gy * h / 16
|
|
if let c = rep.colorAt(x: x, y: y) {
|
|
let r = UInt32(c.redComponent * 255), g = UInt32(c.greenComponent * 255)
|
|
let b = UInt32(c.blueComponent * 255)
|
|
colors.insert((r << 16) | (g << 8) | b)
|
|
}
|
|
}
|
|
}
|
|
return colors.count <= 1 ? "BLANK (1 color)" : "non-blank (\(colors.count) sampled colors)"
|
|
}
|
|
|
|
func injectInput() {
|
|
let center = NSPoint(x: vmView.bounds.midX, y: vmView.bounds.midY)
|
|
postMouse(.mouseMoved, at: center)
|
|
postMouse(.leftMouseDown, at: center)
|
|
postMouse(.leftMouseUp, at: center)
|
|
for ch in "hello" { postKey(String(ch)) }
|
|
postKeyCode(0x24) // return
|
|
}
|
|
|
|
func postMouse(_ type: NSEvent.EventType, at p: NSPoint) {
|
|
guard let e = NSEvent.mouseEvent(
|
|
with: type, location: p, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)
|
|
else { return }
|
|
window.sendEvent(e)
|
|
}
|
|
|
|
func postKey(_ ch: String) {
|
|
let code = KeyMap.stroke(forChord: ch)?.keyCode ?? 0
|
|
postKeyCode(CGKeyCode(code), chars: ch)
|
|
}
|
|
|
|
func postKeyCode(_ code: CGKeyCode, chars: String = "") {
|
|
for type in [NSEvent.EventType.keyDown, .keyUp] {
|
|
if let e = NSEvent.keyEvent(
|
|
with: type, location: .zero, modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
|
|
context: nil, characters: chars, charactersIgnoringModifiers: chars,
|
|
isARepeat: false, keyCode: UInt16(code))
|
|
{
|
|
window.sendEvent(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// VZVirtualMachineDelegate
|
|
func guestDidStop(_ virtualMachine: VZVirtualMachine) { log("[hostio] guest stopped") }
|
|
func virtualMachine(_ vm: VZVirtualMachine, didStopWithError error: Error) {
|
|
log("[hostio] guest stopped with error: \(error)")
|
|
}
|
|
}
|
|
|
|
func run() async throws {
|
|
guard MacVMEngine.isSupported else {
|
|
die(MacVMEngine.unsupportedReason ?? "MacVMEngine.isSupported == false")
|
|
}
|
|
// Uses the app's default storage root, so a base built/provisioned by an earlier run (or the
|
|
// Settings action) is found and reused.
|
|
let engine = MacVMEngine()
|
|
if ProcessInfo.processInfo.environment["NUCLEIC_MACVM_BUILD_BASE"] == "1" {
|
|
try await buildBase(engine)
|
|
exit(0)
|
|
}
|
|
try await runRoundTrip(engine)
|
|
}
|
|
|
|
// Virtualization.framework delivers VM callbacks on dispatch queues; keep the process alive with a
|
|
// run loop while the async work drives the VM (run() calls exit()).
|
|
setbuf(stderr, nil)
|
|
log("macvm-spike: starting")
|
|
Task {
|
|
do { try await run() } catch { die("threw: \(error)") }
|
|
}
|
|
dispatchMain()
|