Add App Bundle Integration

Nucleic-Session: 307EEE49-F1A9-4AF7-B2C1-72C8473CD6B4
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-08 05:02:36 -07:00
co-authored by nucleic
parent 4883b55b9b
commit 7a1352c15e
@@ -0,0 +1,123 @@
import Foundation
/// **Copy user-provided `.app` bundles into a RUNNING guest over vsock** the live-clone analogue of
/// baking them into the base at build time (`scripts/provision-macos-guest.sh` §7½). A session VM
/// already booted from an older base (or booted before the operator added apps in Settings
/// "Included apps") gets the apps without a stop/rebuild: the host tars the bundles and streams the
/// archive straight into a guest `tar -x` over the native agent's `exec` stdin (no share, no network).
extension MacVMEngine {
/// Copy each `.app` in `appPaths` into the named running guest's `/Applications`, replacing any
/// existing copy and stripping quarantine so it launches. Returns the bundle names that landed.
///
/// Best effort on the *input set*: a path that no longer exists or isn't an `.app` bundle is
/// skipped, and an empty/all-invalid set is a no-op returning `[]`. Throws
/// ``MacVMError/appInstallFailed(_:)`` only when the guest transfer itself fails (no agent, a `tar`
/// error), so a caller pushing to several VMs can report per-VM outcomes.
@discardableResult
public func installApps(name: String, appPaths: [String]) async throws -> [String] {
#if arch(arm64)
let fm = FileManager.default
// Keep existing `.app` directories, de-duplicated by destination leaf name (last path wins).
var sourceByLeaf: [String: URL] = [:]
var order: [String] = []
for path in appPaths {
let url = URL(fileURLWithPath: path)
var isDir: ObjCBool = false
guard url.pathExtension.lowercased() == "app",
fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue
else { continue }
let leaf = url.lastPathComponent
if sourceByLeaf[leaf] == nil { order.append(leaf) }
sourceByLeaf[leaf] = url
}
guard !order.isEmpty else { return [] }
// Host side: `tar -c -z` the bundles to stdout, each rooted at the archive top via a per-app
// `-C <parent> <leaf>` (bsdtar applies `-C` positionally to the file that follows).
let tar = Process()
tar.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
var args = ["-c", "-z", "-f", "-"]
for leaf in order {
let src = sourceByLeaf[leaf]!
args.append(contentsOf: ["-C", src.deletingLastPathComponent().path, leaf])
}
tar.arguments = args
let tarOut = Pipe()
tar.standardOutput = tarOut
tar.standardError = Pipe()
do {
try tar.run()
} catch {
throw MacVMError.appInstallFailed("could not read the app bundles on the host: \(error)")
}
// Guest side: drop any existing copy, unpack the streamed archive into /Applications preserving
// permissions, then strip quarantine. The agent account is an admin, and /Applications is
// admin-writable, so no sudo (there's no tty/password on a live clone) is needed.
let quotedNames = order.map(Self.shQuote).joined(separator: " ")
let remoteBody = """
set -e
DEST=/Applications
for n in \(quotedNames); do /bin/rm -rf "$DEST/$n"; done
/usr/bin/tar -x -z -p -f - -C "$DEST"
for n in \(quotedNames); do /usr/bin/xattr -dr com.apple.quarantine "$DEST/$n" 2>/dev/null || true; done
"""
let channel: MacVMExecChannel
do {
channel = try await openExecChannel(
name: name, workdir: nil, env: [:], remoteBody: remoteBody)
} catch {
tar.terminate()
throw MacVMError.appInstallFailed("\(error)")
}
// Pump the tar archive into the guest's stdin on a detached task: `writeStdinRaw` blocks on
// guest backpressure (a slow unpack), which would otherwise park the actor. Natural end-to-end
// flow control a full pipe stalls the host `tar` too so no unbounded buffering.
let reader = tarOut.fileHandleForReading
let streamTask = Task.detached(priority: .utility) { () -> Bool in
let deadline = Date().addingTimeInterval(1800) // 30 min even for a very large bundle set
while true {
let chunk = reader.availableData
if chunk.isEmpty { break } // host tar closed stdout archive complete
if !channel.writeStdinRaw(chunk, deadline: deadline) { return false }
}
channel.closeStdin()
return true
}
// Drain both guest streams concurrently so the exit frame is processed and stderr is captured.
async let stderrText = Self.drainLines(channel.stderrLines)
_ = await Self.drainLines(channel.stdoutLines)
let streamed = await streamTask.value
let code = await channel.wait()
tar.waitUntilExit()
let stderr = await stderrText
guard streamed, code == 0, tar.terminationStatus == 0 else {
let detail = stderr.isEmpty ? "guest exit \(code)" : stderr
throw MacVMError.appInstallFailed("into \"\(name)\": \(detail)")
}
return order
#else
throw MacVMError.unavailable(Self.unsupportedReason ?? "requires Apple silicon")
#endif
}
/// Drain an exec line stream into one newline-joined string, capped so a runaway guest can't
/// balloon host memory (our command emits little, but the cap keeps it honest).
static func drainLines(_ stream: AsyncThrowingStream<Data, Error>, cap: Int = 64 * 1024) async
-> String
{
var lines: [String] = []
var bytes = 0
do {
for try await line in stream where bytes < cap {
lines.append(String(decoding: line, as: UTF8.self))
bytes += line.count + 1
}
} catch { /* stream ended on error return what we have */ }
return lines.joined(separator: "\n")
}
}