Add App Bundle Integration
Nucleic-Session: 307EEE49-F1A9-4AF7-B2C1-72C8473CD6B4 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user