Merge branch 'dev' into canary

This commit is contained in:
2026-07-11 18:44:23 -07:00
21 changed files with 642 additions and 203 deletions
+9 -2
View File
@@ -89,8 +89,15 @@ slower than the guest's own disk.
is the single source of truth: given the working root it prints the `swift` flags to use.
- **Host / container** → nothing printed → the default in-repo `.build/` (unchanged; zero risk).
- **macOS VM (`/Volumes/…`)** → `--scratch-path $HOME/.nucleic-scratch/<hash>` on the guest's
own fast local disk, keyed by a hash of the mount path so distinct shares never collide.
- **macOS VM** → `--scratch-path $HOME/.nucleic-scratch/<hash>` on the guest's own fast local
disk, keyed by a hash of the mount path so distinct shares never collide. This fires whether
the build runs from the `/Volumes/My Shared Files/…` automount **or** from the repo's
re-created original host path (`/Users/…`), which the guest makes a **symlink** onto that
share (see §6.1 of [`docs/MACOS_VM.md`](docs/MACOS_VM.md)). `mac_vm_exec` runs from that
`/Users/…` path by default, so the script resolves symlinks (`pwd -P`) before classifying —
otherwise `.build/` would land on the slow virtio-fs share, where SwiftPM's XCFramework
extraction (Sparkle's binary target) fails: virtio-fs supports neither `clonefile()` nor the
framework symlinks the unpack needs.
The `Makefile` (`SCRATCH := $(shell bash ./scripts/lib/build-scratch.sh)`) and the build scripts
that invoke `swift` (`package-app.sh`, `build-macos-base.sh`, `build-vm-agent.sh`) already
+9 -1
View File
@@ -224,7 +224,15 @@ struct ChatInputField: NSViewRepresentable {
let content = layoutManager.usedRect(for: container).height + inset
let minH = lineHeight + inset
// No taller than half the window; fall back to ~6 lines before it's onscreen.
let windowHalf = textView.window.map { $0.contentLayoutRect.height / 2 }
// In the floating (N/N) presentation the field lives in a sheet whose window
// auto-sizes to this composer's content so measuring "half the window" against
// `textView.window` there is circular: the field grows the sheet grows the
// window grows the cap grows the field grows again, a feedback settle that
// reads as the popup distorting and creeping downward on every keystroke. Cap
// against the sheet's parent (the real app window) instead, which is a stable
// reference; the inline home bar has no sheet parent and keeps using its own window.
let referenceWindow = textView.window?.sheetParent ?? textView.window
let windowHalf = referenceWindow.map { $0.contentLayoutRect.height / 2 }
let maxH = max(minH, windowHalf ?? (lineHeight * 6 + inset))
let clamped = min(max(content, minH), maxH)
guard abs(parent.height - clamped) > 0.5 else { return }
@@ -35,8 +35,9 @@ final class MacVMComputerSurface: MacVMSurfaceHost {
}
private var entries: [String: Entry] = [:]
// Set once at init and read once at deinit (to unregister); `nonisolated(unsafe)` lets the
// nonisolated deinit touch this non-Sendable token without a concurrency race.
// nonisolated deinit touch these non-Sendable tokens without a concurrency race.
private nonisolated(unsafe) var screenObserver: NSObjectProtocol?
private nonisolated(unsafe) var windowCloseObserver: NSObjectProtocol?
init() {
// Attaching or detaching a display makes the WindowServer relocate windows that lie entirely
@@ -49,10 +50,33 @@ final class MacVMComputerSurface: MacVMSurfaceHost {
) { [weak self] _ in
MainActor.assumeIsolated { self?.reparkOffScreenWindows() }
}
// We only ever `orderOut` our windows programmatically, never `close` them, so a real close (the
// only thing that posts `willCloseNotification`) means the user dismissed an on-screen viewer via
// its close button. That's our signal to re-park the surface and let the UI flip its toggle. One
// app-wide observer, filtered to our own windows mirrors `screenObserver`'s no-per-window churn.
windowCloseObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification, object: nil, queue: .main
) { [weak self] note in
guard let window = note.object as? NSWindow else { return }
MainActor.assumeIsolated { self?.handleWindowWillClose(window) }
}
}
deinit {
if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) }
if let windowCloseObserver { NotificationCenter.default.removeObserver(windowCloseObserver) }
}
/// A window is closing. If it's one of our on-screen viewers (diagnostic monitor / operator-assist),
/// the user dismissed it via its close button re-park the surface off-screen so headless capture +
/// HID keep working, and broadcast the surface name so the Settings "Show/Hide VM screen" toggle can
/// flip back to "Show". Ignores unrelated app windows.
private func handleWindowWillClose(_ window: NSWindow) {
guard let name = entries.first(where: { $0.value.window === window })?.key else { return }
NotificationCenter.default.post(name: .macVMObserverWindowClosed, object: name)
// Re-park after the close settles (reconfiguring the window mid-close is unsafe); capture resumes
// off-screen a runloop tick later. Idempotent with the "Hide" path.
Task { @MainActor [weak self] in self?.lockOffScreen(name) }
}
/// Re-assert the off-screen lockdown for every agent-driven surface after a display-topology change.
@@ -185,21 +209,29 @@ final class MacVMComputerSurface: MacVMSurfaceHost {
}
NSApp.activate(ignoringOtherApps: true)
} else {
// Back to the invisible, native-size, borderless HID/capture window and lock it back down
// so it can never again become key or capture the user's keyboard while the agent drives.
if w.isKeyWindow { w.resignKey() }
w.isKeyable = false
w.ignoresMouseEvents = true
w.pinnedOffScreen = true
e.view.capturesSystemKeys = false
w.orderOut(nil)
w.styleMask = [.borderless]
w.setContentSize(NSSize(width: Self.fbWidth, height: Self.fbHeight))
w.setFrameOrigin(Self.offScreenOrigin)
w.orderFrontRegardless()
lockOffScreen(name)
}
}
/// Return a surface's window to the invisible, native-size, borderless HID/capture configuration and
/// lock it back down so it can never again become key or capture the user's keyboard while the agent
/// drives. Shared by the "Hide the monitor" path (`setWindowOnScreen(visible: false)`) and the
/// user-closed-the-window handler (``windowWillClose(_:)``). Idempotent.
private func lockOffScreen(_ name: String) {
guard let e = entries[name] else { return }
let w = e.window
if w.isKeyWindow { w.resignKey() }
w.isKeyable = false
w.ignoresMouseEvents = true
w.pinnedOffScreen = true
e.view.capturesSystemKeys = false
w.orderOut(nil)
w.styleMask = [.borderless]
w.setContentSize(NSSize(width: Self.fbWidth, height: Self.fbHeight))
w.setFrameOrigin(Self.offScreenOrigin)
w.orderFrontRegardless()
}
func cursorPosition(name: String) async -> Point? {
guard let e = entries[name] else { return nil }
return Point(x: Int(e.cursor.x), y: Int(e.cursor.y))
@@ -207,15 +239,34 @@ final class MacVMComputerSurface: MacVMSurfaceHost {
/// Capture the framebuffer and normalize to a 1920×1200 JPEG (`cacheDisplay` returns the view's
/// backing-scale bitmap; downscaling keeps the 1:1 coordinate contract).
///
/// Only the `cacheDisplay` framebuffer grab must run on the main actor it's an AppKit view op. The
/// expensive part (the high-quality downscale + JPEG encode) is handed to a background task via
/// ``encode(_:)`` so it never touches the main thread. The grabbed `NSBitmapImageRep` is freshly
/// allocated per call and touched nowhere else once filled, so moving it off-main is a safe hand-off
/// (boxed because `NSBitmapImageRep` isn't `Sendable`).
func capture(name: String) async -> Data? {
guard let e = entries[name] else { return nil }
let view = e.view
guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return nil }
view.cacheDisplay(in: view.bounds, to: rep)
let target = NSSize(width: CGFloat(Self.fbWidth), height: CGFloat(Self.fbHeight))
let boxed = UncheckedSendableBox(value: rep)
let width = Self.fbWidth, height = Self.fbHeight // read the main-actor constants here, pre-hop
return await Task.detached(priority: .userInitiated) {
Self.encode(boxed.value, width: width, height: height)
}.value
}
/// Downscale a raw backing-scale framebuffer bitmap to a `width`×`height` JPEG. `nonisolated` so it
/// runs on the caller's background task, off the main actor. Drawing into an offscreen
/// `NSBitmapImageRep` via a per-thread `NSGraphicsContext` is safe off-main (the bitmaps are unshared
/// and created here). Dimensions are passed in because the `fbWidth`/`fbHeight` constants are
/// main-actor-isolated.
nonisolated private static func encode(_ rep: NSBitmapImageRep, width: Int, height: Int) -> Data? {
let target = NSSize(width: CGFloat(width), height: CGFloat(height))
guard let out = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: Self.fbWidth, pixelsHigh: Self.fbHeight,
bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)
else { return nil }
+96 -16
View File
@@ -7,7 +7,8 @@ import NucleicCore
/// a build running, a simulator, Setup Assistant, ). Works for either guest OS: a session runs at most
/// one macOS *or* Linux VM at a time, and the monitor tracks whichever is up.
///
/// It polls the VM's current framebuffer a couple of times a second, always host-side from the guest's
/// It polls the VM's current framebuffer up to 30 times a second (and only while its window is on
/// screen), always host-side from the guest's
/// display driver (the `VZVirtualMachineView` framebuffer virtio-gpu on Linux, the Mac paravirtual
/// display on macOS), never via in-guest `screencapture`, and renders the latest frame scaled to fit.
/// It is **read-only**: it shows the screen, it doesn't drive it. When no VM is running for the chat
@@ -34,16 +35,24 @@ struct VMMonitorPanel: View {
/// guest screen does nothing flashing the whole screen tells the user they're tapping on glass and
/// their input isn't reaching the VM. Each flash self-removes when its animation finishes.
@State private var clickFlashes: [ClickFlash] = []
/// Target time between the *start* of consecutive live-frame captures a 60 Hz budget. Capture is
/// host-side (`VZVirtualMachineView.cacheDisplay` + JPEG encode, no in-guest round-trip), so ``poll()``
/// sleeps only the *remainder* of this budget after a capture finishes: the monitor refreshes as fast
/// as that pipeline allows, up to 60 Hz, and self-throttles below it when the main actor is busy
/// (a heavy encode simply eats the whole budget, leaving no sleep). The budget just caps the rate so a
/// cheap tick never spins hotter than 60 Hz.
private static let liveFrameBudget: Duration = .nanoseconds(1_000_000_000 / 60) // 60 Hz
/// Whether this monitor's hosting window is actually on screen. Fed by ``WindowVisibilityReader`` from
/// the window's occlusion state; when false (minimized, fully covered, or on another Space) ``poll()``
/// stands its capture loop down so an unseen monitor never spends the main thread.
@State private var windowVisible = true
/// Target time between the *start* of consecutive live-frame captures a 30 Hz budget. 30 fps keeps
/// the monitor feeling fluid and responsive while leaving the main thread far more headroom than a
/// 60 Hz spin. Only the `cacheDisplay` framebuffer grab runs on the main actor now; the downscale +
/// JPEG encode (surface side) and the JPEG decode (here) both run off it, so ``poll()`` sleeps the
/// *remainder* of this budget after a capture and the budget actually paces the loop instead of
/// collapsing to zero sleep under a heavy main-thread encode.
private static let liveFrameBudget: Duration = .nanoseconds(1_000_000_000 / 30) // 30 Hz
/// Refresh interval for a monitor with nothing new to show a paused guest serves a frozen still and
/// an empty/booting stage has no moving frame, so poll them lazily rather than spinning at 60 Hz.
/// an empty/booting stage has no moving frame, so poll them lazily rather than spinning at 30 Hz.
private static let idleFrameInterval: Duration = .milliseconds(2000)
/// How often a monitor whose window is off screen re-checks whether it's back on screen. It captures
/// nothing while dormant (nobody can see it), so this is just the wake-up latency when the window
/// returns short enough to feel instant.
private static let dormantPollInterval: Duration = .milliseconds(500)
/// What the monitor is currently showing / waiting on. Drives the placeholder when there's no frame.
private enum Status: Equatable {
@@ -65,8 +74,14 @@ struct VMMonitorPanel: View {
var body: some View {
ZStack {
// A black stage, like a physical monitor: the guest screen sits letterboxed in it, and the
// placeholder reads as an "off" display when nothing's live.
Color.black
// placeholder reads as an "off" display when nothing's live. The floating PiP omits this
// behind a live frame so its edges are the guest image itself: the window is exactly the
// guest's 16:10, so any sub-point gap in the aspect-fit would otherwise leak a thin black
// outline around all four sides. Dropping the backdrop lets that hairline fall through to
// the transparent window instead an entirely frameless PiP.
if !(isPictureInPicture && frame != nil) {
Color.black
}
if let frame {
// Frame + overlays share one aspect-fitted stage so any overlay covers only the letterboxed
// display output, not the black bars around it.
@@ -102,6 +117,8 @@ struct VMMonitorPanel: View {
guard frame != nil else { return }
clickFlashes.append(ClickFlash())
}
// Track whether the hosting window is on screen, so `poll()` can stand down when it isn't.
.background(WindowVisibilityReader(isVisible: $windowVisible))
// Restart polling when the open chat changes (a different chat means a different VM).
.task(id: session?.id) { await poll() }
}
@@ -184,6 +201,13 @@ struct VMMonitorPanel: View {
return
}
while !Task.isCancelled {
// Stand down while the hosting window isn't on screen (minimized, fully covered, or on another
// Space): nobody can see this monitor, so don't spend the main actor capturing for it. Keep the
// last good frame so it's there instantly when the window returns; re-check on a short interval.
if !windowVisible {
try? await Task.sleep(for: Self.dormantPollInterval)
continue
}
// The floating PiP is showing this same guest, and the two capture over the identical (and
// not-free) screenshot path. Drop the frame and stand down until it retracts.
if !isPictureInPicture, let session, VMMonitorPiPState.shared.visibleSessionID == session.id {
@@ -201,8 +225,8 @@ struct VMMonitorPanel: View {
// Match whichever of the session's guests (macOS or Linux) is actually up, and capture it
// by its real name the two never run at once for one session.
if let entry = running.first(where: { candidates.contains($0.name) }) {
if let base64 = await store.captureMacVMScreen(name: entry.name),
let image = Self.decode(base64) {
if let data = await store.captureMacVMScreenData(name: entry.name),
let image = await Self.decode(data) {
frame = image
status = .live
} else if frame == nil {
@@ -236,9 +260,14 @@ struct VMMonitorPanel: View {
}
}
private static func decode(_ base64: String) -> NSImage? {
guard let data = Data(base64Encoded: base64) else { return nil }
return NSImage(data: data)
/// Decode a JPEG frame into an `NSImage` **off the main actor** a full-frame JPEG decode is far too
/// heavy to run on the thread that also handles scrolling and keystrokes. The result is handed back
/// across the actor boundary in an `UncheckedSendableBox` (`NSImage` isn't `Sendable`); it's freshly
/// created and touched nowhere off-main after this, so the hand-off is safe.
private static func decode(_ data: Data) async -> NSImage? {
await Task.detached(priority: .userInitiated) {
UncheckedSendableBox(value: NSImage(data: data))
}.value.value
}
private var placeholderIcon: String {
@@ -289,3 +318,54 @@ private struct ClickFlashView: View {
}
}
}
/// Reports whether the monitor's hosting window is actually on screen, so the capture loop can stand
/// down when nobody can see it. Reads the window's `occlusionState`: AppKit clears `.visible` when the
/// window is minimized, fully covered by other windows, or on another Space exactly the cases where a
/// 30 Hz framebuffer capture would be pure wasted main-thread work. Works for both the docked monitor
/// (in the main window) and the PiP/fan cards (each in its own floating panel).
private struct WindowVisibilityReader: NSViewRepresentable {
@Binding var isVisible: Bool
func makeNSView(context: Context) -> TrackerView {
let view = TrackerView()
view.onChange = { visible in if isVisible != visible { isVisible = visible } }
return view
}
func updateNSView(_ nsView: TrackerView, context: Context) {}
/// Observes its own window's occlusion state and reports `.visible` changes. Re-subscribes whenever it
/// moves to a new window (the PiP reuses one panel across sessions, but this is cheap and robust).
final class TrackerView: NSView {
var onChange: ((Bool) -> Void)?
// Set/read on the main actor and once in the nonisolated `deinit` to unregister; the token is a
// non-Sendable value the deinit must touch, so mark it `nonisolated(unsafe)` (matches the
// observer tokens in `MacVMComputerSurface`).
private nonisolated(unsafe) var observer: NSObjectProtocol?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let observer { NotificationCenter.default.removeObserver(observer); self.observer = nil }
guard let window else { notify(true); return } // not in a window yet: assume visible
notify(window.occlusionState.contains(.visible))
observer = NotificationCenter.default.addObserver(
forName: NSWindow.didChangeOcclusionStateNotification, object: window, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self, let w = self.window else { return }
self.notify(w.occlusionState.contains(.visible))
}
}
}
/// Report a visibility change off the current call stack: `viewDidMoveToWindow` can fire inside a
/// SwiftUI update pass, and writing `@State` synchronously there trips the "modifying state during
/// update" warning. A main-actor `Task` hop lands the write on the next turn cheap and rare.
private func notify(_ visible: Bool) {
Task { @MainActor [weak self] in self?.onChange?(visible) }
}
deinit { if let observer { NotificationCenter.default.removeObserver(observer) } }
}
}
@@ -20,6 +20,21 @@ final class VMMonitorPiPState {
private init() {}
}
/// Drives the featured PiP card's *stack hint*: when other background VMs are available the edge of one
/// more card peeks out on the side the deck fans toward, and that peek hides while the deck is fanned
/// open. Reactive so toggling these never rebuilds the hosting view (which would restart the capture).
@MainActor
@Observable
final class VMMonitorPiPStackModel {
/// There is more than one background VM, so hint a stack by peeking a card edge on the fan side.
var hasOthers = false
/// Which side the deck fans toward (and the peek shows on): `true` = trailing/right, `false` = leading/left.
var fanRight = true
/// The deck is currently fanned open hide the resting peek while the real sibling cards are out.
var fanned = false
fileprivate init() {}
}
/// **Picture-in-Picture VM monitor.** A small always-on-top window that shows a background chat's live VM
/// screen so the user can keep watching that guest while working in and over any other window, even
/// another app. It hosts the same read-only ``VMMonitorPanel`` the in-chat monitor uses, so like every
@@ -28,10 +43,11 @@ final class VMMonitorPiPState {
/// host's mouse or keyboard**. The floating window itself is a non-activating panel that can't become
/// key/main, so clicking or dragging it never steals focus or routes input at the guest.
///
/// **Stacking + fan-out.** When more than one background session has a running VM, the featured window
/// reads as a *stack* of cards, and hovering it fans out a sibling PiP window per background VM (see
/// ``fanOut(store:)``). The fan is **space-aware**: it cascades toward whichever corner of the display
/// has room (a PiP parked top-left fans down-and-right). Clicking a fanned card promotes that VM to the
/// **Stacking + fan-out.** When more than one background session has a running VM, the featured card
/// hints a *deck*: the edge of one more card peeks out on the side there is room to fan toward. Hovering
/// slides the deck open horizontally a sibling PiP window per other background VM, each **sliding out
/// from behind** the card in front of it (see ``fanOut(store:)``), coming to rest in a non-overlapping
/// row straight off to the side (never into a corner). Clicking a fanned card promotes that VM to the
/// featured "top" window (``select(_:store:)``) a transient pick the auto-driver keeps until the VM
/// stops or its chat is opened. Hover uses an `.activeAlways` `NSTrackingArea` (see ``HoverTrackingView``)
/// rather than SwiftUI `.onHover`, because the PiP floats over *other* apps while Nucleic is in the
@@ -53,9 +69,6 @@ final class VMMonitorPiPController: NSObject, NSWindowDelegate {
private var panel: NSPanel?
/// The session the featured window is currently rendering, so content is rebuilt only when it changes.
private var shownSession: SessionID?
/// The stack depth (extra cards drawn behind the featured card) baked into the current featured
/// content, so the "stacked" hint is rebuilt only when the number of background VMs actually changes.
private var lastDepth = 0
/// The session whose PiP the user explicitly closed kept closed until they switch chats (or its
/// VM disappears), so the 2-second auto-driver doesn't immediately reopen it under them.
private var dismissedSession: SessionID?
@@ -63,17 +76,19 @@ final class VMMonitorPiPController: NSObject, NSWindowDelegate {
/// The ordered background sessions with running guests (featured first), as handed in by RootView.
/// The featured window shows `candidates.first`; hovering fans the rest.
private var candidates: [Session] = []
/// The transient fan-out windows, one per non-featured candidate, keyed by session. Non-nil only
/// while the stack is fanned open (on hover); torn down on collapse, which cancels their captures.
/// The transient fan-out windows, one per non-featured candidate, keyed by session. Non-empty only
/// while the deck is fanned open (on hover); torn down on collapse, which cancels their captures.
private var fanPanels: [SessionID: NSPanel] = [:]
private var fanShown = false
/// Reactive stack hint (peek edge / fan side / fanned) shared into the featured card's SwiftUI content.
private let stackModel = VMMonitorPiPStackModel()
/// The `AppStore` from the most recent `show`, so a hover (which originates in a tracking-area
/// callback, not a RootView call) can build fan cards without threading the store through.
private var currentStore: AppStore?
/// Which windows the cursor is currently over the featured one keys on `"featured"`, each fan card
/// on its session's raw id. The fan stays open while this is non-empty and collapses (after a short
/// grace) once it drains, so crossing the small gaps between cards doesn't dismiss it.
/// grace) once it drains, so crossing the gaps between cards doesn't dismiss it.
private var hoveredTokens: Set<String> = []
private static let featuredToken = "featured"
/// Debounces collapse so moving the cursor between the featured window and a fan card briefly over
@@ -81,29 +96,29 @@ final class VMMonitorPiPController: NSObject, NSWindowDelegate {
private var collapseTask: Task<Void, Never>?
private static let collapseGrace: Duration = .milliseconds(250)
/// Gap between adjacent fanned cards so they never overlap (they slide clear of one another).
private static let fanGap: CGFloat = 12
private static let fanOutDuration: TimeInterval = 0.30
private static let fanInDuration: TimeInterval = 0.22
private override init() { super.init() }
/// Show (or update) the floating PiP. `session` is the featured guest; `candidates` is the ordered
/// background sessions (featured first) that the stack fans out to. Idempotent: re-showing the same
/// featured chat just refreshes the stack depth and brings it forward. Respects a manual close.
/// background sessions (featured first) that the deck fans out to. Idempotent: re-showing the same
/// featured chat just refreshes the stack hint and brings it forward. Respects a manual close.
func show(session: Session, candidates: [Session], store: AppStore) {
// The user closed the PiP for exactly this chat's VM honor that until the chat changes.
if dismissedSession == session.id { return }
dismissedSession = nil
currentStore = store
self.candidates = candidates
let depth = stackDepth(for: candidates)
if let panel {
if shownSession != session.id {
panel.contentView = featuredContainer(for: session, store: store, depth: depth)
panel.contentView = featuredContainer(for: session, store: store)
shownSession = session.id
lastDepth = depth
} else if lastDepth != depth {
// Same guest, but background VMs came or went refresh the stacked-card hint.
panel.contentView = featuredContainer(for: session, store: store, depth: depth)
lastDepth = depth
}
updateStackHint()
if !panel.isVisible { panel.orderFrontRegardless() }
VMMonitorPiPState.shared.setVisibleSession(session.id)
return
@@ -111,12 +126,12 @@ final class VMMonitorPiPController: NSObject, NSWindowDelegate {
let panel = makePanel()
panel.delegate = self
panel.contentView = featuredContainer(for: session, store: store, depth: depth)
panel.contentView = featuredContainer(for: session, store: store)
Self.positionTopTrailing(panel)
panel.orderFrontRegardless()
self.panel = panel
self.shownSession = session.id
self.lastDepth = depth
updateStackHint()
VMMonitorPiPState.shared.setVisibleSession(session.id)
}
@@ -124,7 +139,7 @@ final class VMMonitorPiPController: NSObject, NSWindowDelegate {
/// turned off). Keeps the panel instance for reuse and clears any manual-dismissal so a returning VM
/// shows again.
func hide() {
collapseFan()
collapseFan(animated: false)
panel?.orderOut(nil)
shownSession = nil
dismissedSession = nil
@@ -137,54 +152,95 @@ final class VMMonitorPiPController: NSObject, NSWindowDelegate {
/// still honors a manual close made before the pause. No-op when already hidden.
func hideForSuspension() {
guard let panel, panel.isVisible else { return }
collapseFan()
collapseFan(animated: false)
panel.orderOut(nil)
VMMonitorPiPState.shared.setVisibleSession(nil)
}
/// The W close sticks for the current chat's VM until the user switches chats.
func windowWillClose(_ notification: Notification) {
collapseFan()
collapseFan(animated: false)
dismissedSession = shownSession
VMMonitorPiPState.shared.setVisibleSession(nil)
}
// MARK: - Fan-out
/// Number of extra cards to draw behind the featured one (0, 1, or 2) a hint that there's a stack
/// to fan out. Capped at two so the peek stays legible regardless of how many VMs are running.
private func stackDepth(for candidates: [Session]) -> Int {
min(max(candidates.count - 1, 0), 2)
/// Keep the peek/fan side pointed at open space as the user drags the featured window around.
func windowDidMove(_ notification: Notification) {
updateStackHint()
}
/// Fan the stack open: a sibling PiP window per non-featured background VM, cascaded from the
/// featured window toward open screen space. Cheap to tear down the windows (and their captures)
/// exist only while hovering.
// MARK: - Stack hint
/// Refresh the resting stack hint from the current candidates and window position: peek a card edge
/// only when there are other VMs, on whichever side has room to fan.
private func updateStackHint() {
stackModel.hasOthers = candidates.count > 1
if let panel { stackModel.fanRight = Self.fansRight(for: panel.frame, screen: panel.screen) }
}
// MARK: - Fan-out
/// Fan the deck open: a sibling PiP window per non-featured background VM. Each starts stacked exactly
/// behind the featured card (and ordered below it) and **slides out from behind** it and from behind
/// the card ahead of it into a non-overlapping row straight off to the side. Cheap to tear down: the
/// windows (and their captures) exist only while hovering.
private func fanOut(store: AppStore) {
guard let panel, candidates.count > 1 else { return }
let others = candidates.filter { $0.id != shownSession }
guard !others.isEmpty else { return }
collapseFan() // clear any stragglers before rebuilding
let frames = Self.fanFrames(around: panel.frame, screen: panel.screen, count: others.count)
collapseFan(animated: false) // clear any stragglers before rebuilding
let right = Self.fansRight(for: panel.frame, screen: panel.screen)
stackModel.fanRight = right
stackModel.fanned = true
let slots = Self.fanSlots(around: panel.frame, screen: panel.screen, count: others.count, right: right)
// Slide each card out from *behind* the one ahead of it: card 0 sits just under the featured
// window, card 1 just under card 0, and so on, all starting fully occluded at the featured frame.
var below = panel.windowNumber
var animations: [(NSPanel, NSRect)] = []
for (index, session) in others.enumerated() {
let card = makePanel()
card.contentView = fanContainer(for: session, store: store)
if index < frames.count { card.setFrame(frames[index], display: true) }
card.orderFrontRegardless()
card.setFrame(panel.frame, display: false)
card.order(.below, relativeTo: below)
below = card.windowNumber
fanPanels[session.id] = card
animations.append((card, index < slots.count ? slots[index] : panel.frame))
}
fanShown = true
NSAnimationContext.runAnimationGroup { context in
context.duration = Self.fanOutDuration
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
for (card, slot) in animations { card.animator().setFrame(slot, display: true) }
}
}
/// Retract every fanned card and drop their hover tokens (the featured window's is kept if the cursor
/// is still over it). Cancels the pending collapse timer.
private func collapseFan() {
/// Retract every fanned card (sliding them back behind the featured window when `animated`) and drop
/// their hover tokens the featured window's is kept if the cursor is still over it. Cancels the
/// pending collapse timer and un-hides the resting peek.
private func collapseFan(animated: Bool) {
collapseTask?.cancel()
collapseTask = nil
for card in fanPanels.values { card.orderOut(nil) }
let cards = Array(fanPanels.values)
fanPanels.removeAll()
fanShown = false
stackModel.fanned = false
hoveredTokens = hoveredTokens.intersection([Self.featuredToken])
guard !cards.isEmpty else { return }
guard animated, let home = panel?.frame else {
for card in cards { card.orderOut(nil) }
return
}
NSAnimationContext.runAnimationGroup({ context in
context.duration = Self.fanInDuration
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
for card in cards { card.animator().setFrame(home, display: true) }
}, completionHandler: {
// AppKit fires this on the main thread once the slide-back finishes; assert that isolation
// so ordering the (main-actor) windows out doesn't trip Swift 6 concurrency checking.
MainActor.assumeIsolated { for card in cards { card.orderOut(nil) } }
})
}
/// Promote a fanned card to the featured "top" window. Records the pick as a transient bias
@@ -193,13 +249,12 @@ final class VMMonitorPiPController: NSObject, NSWindowDelegate {
private func select(_ session: Session, store: AppStore) {
VMMonitorPiPState.shared.userFeaturedSessionID = session.id
if let panel, shownSession != session.id {
let depth = stackDepth(for: candidates)
panel.contentView = featuredContainer(for: session, store: store, depth: depth)
panel.contentView = featuredContainer(for: session, store: store)
shownSession = session.id
lastDepth = depth
updateStackHint()
VMMonitorPiPState.shared.setVisibleSession(session.id)
}
collapseFan()
collapseFan(animated: false)
}
// MARK: - Hover coordination
@@ -218,33 +273,33 @@ final class VMMonitorPiPController: NSObject, NSWindowDelegate {
collapseTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: Self.collapseGrace)
guard let self, !Task.isCancelled, self.hoveredTokens.isEmpty else { return }
self.collapseFan()
self.collapseFan(animated: true)
}
}
// MARK: - Geometry
/// Frames for `count` fan cards cascading from the featured window toward the screen corner with the
/// most room: a window in the left half fans right, in the top half fans down, and so on. Cards are
/// the featured size and **overlap** (step < card size) so the cursor never crosses a true gap moving
/// between them; each is clamped inside the visible frame. Recomputed from the live frame on every
/// fan-out, so it follows a window the user has dragged.
static func fanFrames(around featured: NSRect, screen: NSScreen?, count: Int) -> [NSRect] {
/// Which side the deck fans toward: whichever has more room between the featured window and the edge
/// of its screen, so the cards slide into open space rather than off-screen. Defaults to the right.
private static func fansRight(for featured: NSRect, screen: NSScreen?) -> Bool {
guard let visible = (screen ?? NSScreen.main)?.visibleFrame else { return true }
let roomRight = visible.maxX - featured.maxX
let roomLeft = featured.minX - visible.minX
return roomRight >= roomLeft
}
/// Frames for `count` fan cards laid out **straight to the side** in a single row same height/`y` as
/// the featured window, stepped by a full card width plus a gap so they never overlap. Each is clamped
/// inside the visible frame. Computed from the live featured frame, so it follows a dragged window.
static func fanSlots(around featured: NSRect, screen: NSScreen?, count: Int, right: Bool) -> [NSRect] {
let size = featured.size
let stepX = size.width * 0.42
let stepY = size.height * 0.42
let visible = (screen ?? NSScreen.main)?.visibleFrame
// Room to the right of center fan right (+x); upper half fan down (y, AppKit is y-up).
let dxDir: CGFloat = (visible.map { featured.midX < $0.midX } ?? true) ? 1 : -1
let dyDir: CGFloat = (visible.map { featured.midY > $0.midY } ?? true) ? -1 : 1
let direction: CGFloat = right ? 1 : -1
let step = size.width + fanGap
return (1...max(count, 1)).prefix(count).map { i in
var x = featured.origin.x + dxDir * stepX * CGFloat(i)
var y = featured.origin.y + dyDir * stepY * CGFloat(i)
if let visible {
x = min(max(x, visible.minX), visible.maxX - size.width)
y = min(max(y, visible.minY), visible.maxY - size.height)
}
return NSRect(x: x, y: y, width: size.width, height: size.height)
var x = featured.origin.x + direction * step * CGFloat(i)
if let visible { x = min(max(x, visible.minX), visible.maxX - size.width) }
return NSRect(x: x, y: featured.origin.y, width: size.width, height: size.height)
}
}
@@ -290,25 +345,20 @@ final class VMMonitorPiPController: NSObject, NSWindowDelegate {
return panel
}
/// The featured window's content: the live monitor, wrapped in the stacked-card hint when other
/// background VMs exist, draggable anywhere, and hover-tracked so entering it fans the stack.
private func featuredContainer(for session: Session, store: AppStore, depth: Int) -> NSView {
/// The featured window's content: the live monitor, wrapped in the reactive stack hint (a peeking
/// card edge when other background VMs exist), draggable anywhere, and hover-tracked so entering it
/// fans the deck.
private func featuredContainer(for session: Session, store: AppStore) -> NSView {
let monitor = VMMonitorPanel(session: session, isPictureInPicture: true)
.environment(store)
.frame(minWidth: 240, minHeight: 150)
let root = AnyView(
Group {
if depth > 0 {
PiPStackedMonitor(depth: depth) { monitor }
} else {
monitor
}
}
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
// With the title bar gone there's no handle to move the window by, and
// `isMovableByWindowBackground` never fires because the hosting view swallows the drag.
// This gives the whole monitor back as the drag handle.
.gesture(WindowDragGesture()))
VMMonitorPiPStackHint(model: stackModel) { monitor }
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
// With the title bar gone there's no handle to move the window by, and
// `isMovableByWindowBackground` never fires because the hosting view swallows the drag.
// This gives the whole monitor back as the drag handle.
.gesture(WindowDragGesture()))
return hostContainer(root: root, token: Self.featuredToken)
}
@@ -382,34 +432,32 @@ private final class HoverTrackingView: NSView {
override func mouseExited(with event: NSEvent) { onExit?() }
}
/// The featured PiP card drawn as the top of a *stack*: `depth` dimmed rounded-rect silhouettes peek from
/// behind its top-right corner, hinting that hovering fans out the other running VMs. The live `content`
/// is inset just enough on the top and trailing edges to reveal them.
private struct PiPStackedMonitor<Content: View>: View {
let depth: Int
/// The featured PiP card's *stack hint*: when other background VMs are available (`model.hasOthers`) and
/// the deck isn't fanned open (`!model.fanned`), the edge of one more card peeks out on the fan side
/// hinting there's a card behind this one and which way it slides. The live `content` is inset a few
/// points on that side to reveal the peek, and the hint fades away as the real cards slide out.
private struct VMMonitorPiPStackHint<Content: View>: View {
let model: VMMonitorPiPStackModel
@ViewBuilder let content: () -> Content
private let peek: CGFloat = 7
private let peek: CGFloat = 8
var body: some View {
let inset = CGFloat(depth) * peek
ZStack(alignment: .bottomLeading) {
ForEach(1...max(depth, 1), id: \.self) { i in
// Card `i` (1 = just behind depth = farthest back) sits progressively higher and more
// to the right, so each corner peeks past the one in front of it.
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.black)
let showing = model.hasOthers && !model.fanned
ZStack {
if showing {
// A card tucked behind this one, its rounded edge peeking out on the fan side. A dark
// gray (not the monitor's black) plus a hairline border so the edge reads as a *separate*
// card behind, not just a seam.
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(white: 0.17))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Color.white.opacity(0.14), lineWidth: 1))
.opacity(0.5)
.padding(.top, inset - CGFloat(i) * peek)
.padding(.trailing, inset - CGFloat(i) * peek)
.padding(.leading, CGFloat(i) * peek)
.padding(.bottom, CGFloat(i) * peek)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
.transition(.move(edge: model.fanRight ? .trailing : .leading).combined(with: .opacity))
}
content()
.padding(.top, inset)
.padding(.trailing, inset)
.padding(model.fanRight ? .trailing : .leading, showing ? peek : 0)
}
.animation(.easeOut(duration: 0.18), value: showing)
}
}
+34 -9
View File
@@ -560,6 +560,16 @@ private struct MacVMSettingsTab: View {
private var supported: Bool { store.macVMSupported }
/// True when every configured "Included app" has already been staged into the installed base image
/// either by a prior Import or automatically during base provisioning (both record the app's path
/// in ``MacVMBaseStatus/installedAppPaths``). With nothing left to import, the Import button greys
/// out. `false` when there are no configured apps (there's simply nothing to import).
private var allIncludedAppsImported: Bool {
guard let baseStatus, !bundledApps.isEmpty else { return false }
let imported = Set(baseStatus.installedAppPaths)
return bundledApps.allSatisfy { imported.contains($0) }
}
/// True while at least one of the two guest services is enabled the gate for the shared
/// session-default and resource controls, which are meaningless with no VM to run.
private var anyServiceEnabled: Bool { serviceEnabled || linuxServiceEnabled }
@@ -579,6 +589,15 @@ private struct MacVMSettingsTab: View {
}
.formStyle(.grouped)
.task { await poll() }
.onReceive(NotificationCenter.default.publisher(for: .macVMObserverWindowClosed)) { note in
// The user closed the base-build diagnostic monitor window directly (red X) instead of via
// "Hide VM screen" flip the toggle back to "Show" and clear the engine's remembered
// request so it doesn't pop back. (Ignore closes of any other surface's viewer.)
guard note.object as? String == MacVMEngine.baseProvisionSurfaceName, observingBaseVM
else { return }
observingBaseVM = false
Task { await store.setMacVMBaseObserver(visible: false) }
}
.onAppear {
reconcileBaseImageChoice()
bundledApps = MacVMSettings.bundledAppPaths
@@ -665,15 +684,19 @@ private struct MacVMSettingsTab: View {
+ "gigabytes and reboots the guest a few times. You can leave this and keep working; "
+ "it runs in the background.")
.settingsCaption()
Button {
Task {
observingBaseVM.toggle()
await store.setMacVMBaseObserver(visible: observingBaseVM)
// Once the build reaches `.ready` the provisioning VM has already powered off and its
// surface detached (there's no live screen left to show), so drop the toggle entirely.
if baseProgress.phase != .ready {
Button {
Task {
observingBaseVM.toggle()
await store.setMacVMBaseObserver(visible: observingBaseVM)
}
} label: {
Label(
observingBaseVM ? "Hide VM screen" : "Show VM screen",
systemImage: observingBaseVM ? "eye.slash" : "eye")
}
} label: {
Label(
observingBaseVM ? "Hide VM screen" : "Show VM screen",
systemImage: observingBaseVM ? "eye.slash" : "eye")
}
} else if let baseStatus, baseStatus.installed {
// Built: the status line reads as "Ready ", with the destructive/rebuild actions
@@ -771,7 +794,9 @@ private struct MacVMSettingsTab: View {
addingToBase ? "Importing…" : "Import",
systemImage: "square.and.arrow.down.on.square")
}
.disabled(addingToBase || building || deleting || bundledApps.isEmpty)
.disabled(
addingToBase || building || deleting || bundledApps.isEmpty
|| allIncludedAppsImported)
}
if !bundledApps.isEmpty {
Spacer()
+7
View File
@@ -2174,6 +2174,13 @@ public final class AppStore: ConflictArbiter {
return await macVMManager.captureScreen(name: name)
}
/// Raw JPEG `Data` of a running per-session VM's screen for the live monitor's fast path skips the
/// base64 encode/decode round-trip the `String` overload incurs. `nil` when unavailable.
public func captureMacVMScreenData(name: String) async -> Data? {
guard let macVMManager else { return nil }
return await macVMManager.captureScreenData(name: name)
}
/// Whether the named per-session VM is running a headless `mac_vm_exec` command right now with no
/// screen-driving (`mac_vm_computer`) action in flight the guest is working but its screen is
/// static. The live monitor polls this to overlay a "Working in the background" hint so a frozen
+1
View File
@@ -360,6 +360,7 @@ public actor MacVMManager {
public func runningVMs() async -> [MacVMEntry] { [] }
public func baseMaintenanceVM() async -> MacVMMaintenanceInfo? { nil }
public func captureScreen(name: String) async -> String? { nil }
public func captureScreenData(name: String) async -> Data? { nil }
public func sampleResourceUsage(name: String) async -> MacVMResourceSample? { nil }
public func baseProgress() async -> MacVMBaseProgress? { nil }
public func baseImageOSVersion() async -> String? { nil }
@@ -119,11 +119,15 @@ extension MacVMEngine {
throw MacVMError.baseImageMissing("no base image has been built yet")
}
baseRecoveryActive = true
syncBackgroundActivity() // the Recovery VM never enters `live` keep the app off App Nap
return path
}
/// Release the base after a Recovery window closes (its VM powered off), re-allowing builds/clones.
public func endBaseRecovery() { baseRecoveryActive = false }
public func endBaseRecovery() {
baseRecoveryActive = false
syncBackgroundActivity()
}
/// Delete the engine-built golden base so the next ``buildBaseImage`` reinstalls macOS **from
/// scratch** (the full install path, not the reentrant re-provision). Removes the base bundle and,
@@ -371,7 +375,15 @@ extension MacVMEngine {
: "a base image build is already in progress")
}
baseBuilding = true
defer { baseBuilding = false }
// Hold the anti-nap assertion for the whole build/provision pass: the base VM booted below never
// enters `live`, so without this an auto-rebuild that fires at launch (e.g. after an update,
// while the app is still backgrounded) would be App-Napped, freezing the main-queue provisioning
// VM + its HID and wedging the build at "Waiting for the guest desktop". Released in the defer.
syncBackgroundActivity()
defer {
baseBuilding = false
syncBackgroundActivity()
}
let fm = FileManager.default
// Reentrancy: if the base is already installed + account-provisioned, skip the multi-GB
@@ -119,6 +119,9 @@ extension MacVMEngine {
}
baseAppInstalling = true
// Hold the anti-nap assertion for the boot (the base VM never enters `live`, which is what the
// assertion otherwise keys off) so a backgrounded app isn't App-Napped mid-injection.
syncBackgroundActivity()
// Publish the base as a running, system-managed VM so the Control panel's Virtual Machines
// section shows it (with a live activity note) alongside the per-session guests while apps
// install the user's indication that the base is booted and working.
@@ -127,6 +130,7 @@ extension MacVMEngine {
defer {
baseAppInstalling = false
baseMaintenance = nil
syncBackgroundActivity()
}
let config = try Self.makeConfiguration(
@@ -66,6 +66,9 @@ extension MacVMEngine {
}
baseAppInstalling = true
// Hold the anti-nap assertion for the boot (the base VM never enters `live`, which is what the
// assertion otherwise keys off) so a backgrounded app isn't App-Napped mid-install.
syncBackgroundActivity()
// Publish the base as a running, system-managed VM so the Control panel shows it (with a live
// activity note) alongside the per-session guests while packages install.
baseMaintenance = MacVMMaintenanceInfo(
@@ -73,6 +76,7 @@ extension MacVMEngine {
defer {
baseAppInstalling = false
baseMaintenance = nil
syncBackgroundActivity()
}
let config = try Self.makeConfiguration(
@@ -91,7 +91,14 @@ extension MacVMEngine {
return
}
baseBuilding = true
defer { baseBuilding = false }
// Hold the anti-nap assertion for the whole build (the assemble/provision VMs never enter
// `live`), so a Linux base rebuild that fires at launch after an update isn't App-Napped or
// auto-terminated mid-build. Released in the defer.
syncBackgroundActivity()
defer {
baseBuilding = false
syncBackgroundActivity()
}
let fm = FileManager.default
try fm.createDirectory(at: linuxArtifactsDir, withIntermediateDirectories: true)
@@ -205,6 +205,12 @@ extension MacVMEngine {
.appendingPathComponent("nucleic-provision-\(UUID().uuidString)", isDirectory: true)
try fm.createDirectory(at: stageDir, withIntermediateDirectories: true)
defer { try? fm.removeItem(at: stageDir) }
// Surface the provisioning base as a system-managed VM in the Control panel's Virtual Machines
// section (mirrors the app/package-injection boots), so the running base is visible while it's
// being built with a live activity note that tracks the provisioning phase. Cleared on every
// exit path. The row is otherwise absent for a base build, which reads as "nothing is happening"
// when a long provisioning pass (or a wedged one) is actually in flight.
defer { baseMaintenance = nil }
try? fm.copyItem(
at: scriptURL, to: stageDir.appendingPathComponent("provision-macos-guest.sh"))
@@ -265,14 +271,19 @@ extension MacVMEngine {
to: bootstrapURL, atomically: true, encoding: .utf8)
try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: bootstrapURL.path)
// 2. Boot with the RW share mounted, on the MAIN queue so a host surface can bind.
// 2. Boot with the RW share mounted, on a dedicated background queue (never main). The
// provisioning surface binds its `VZVirtualMachineView` on the main thread, but the VM
// itself runs off-main like every other VM.
let mac = Self.randomMAC()
let mount = MacVMSpec.Mount(host: stageDir.path, name: "nucleic-provision", readOnly: false)
let config = try Self.makeConfiguration(
bundle: bundle, cpus: MacVMSettings.vmCPUs, memoryGiB: MacVMSettings.vmMemoryGiB,
mac: mac, mounts: [mount])
let instance = MacVMInstance(
configuration: config, label: Self.baseProvisionSurfaceName, mainQueue: true)
configuration: config, label: Self.baseProvisionSurfaceName)
baseMaintenance = MacVMMaintenanceInfo(
name: Self.baseProvisionSurfaceName, os: .macOS,
activity: "Booting the base image…", booting: true)
do {
if declarativeFirstBoot, #available(macOS 27.0, *) {
try await instance.startWithProvisioning(
@@ -377,6 +388,10 @@ extension MacVMEngine {
? (Self.lastProvisionLogLine(at: logURL) ?? "Running the provisioner in the guest…")
: "Waiting for the guest desktop, then launching the provisioner…"
baseProgress = MacVMBaseProgress(phase: phase, fraction: nil, detail: detail)
// Keep the Control panel's "Base image" row in step with the build's live sub-step (the
// guest is up and no longer "booting" once the bootstrap has signalled STARTED).
baseMaintenance = MacVMMaintenanceInfo(
name: name, os: .macOS, activity: detail, booting: !running)
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
return nil
@@ -409,8 +424,16 @@ extension MacVMEngine {
await pause(1.0)
await surface.send(name: name, .key(chord: "return"))
await pause(3.0) // Terminal cold-launch
// Terminal has just taken keyboard focus, and the FIRST synthesized keystroke after a focus
// change is intermittently dropped by the guest which turns "/bin/bash " into "bin/bash "
// ("command not found", re-typed every retry until a keystroke finally lands). Guard the leading
// slash two ways: (1) a throwaway warm-up keystroke absorbs a dropped first key before the real
// command, and (2) the command itself leads with a space, so if a key is still dropped it costs
// that harmless space rather than the slash (bash ignores leading whitespace before a command).
await surface.send(name: name, .text(" "))
await pause(0.5)
// Run the staged bootstrap from the share.
let command = "/bin/bash '/Volumes/My Shared Files/nucleic-provision/\(bootstrapScriptName)'"
let command = " /bin/bash '/Volumes/My Shared Files/nucleic-provision/\(bootstrapScriptName)'"
await surface.send(name: name, .text(command))
await surface.send(name: name, .key(chord: "return"))
}
@@ -23,7 +23,12 @@ extension MacVMEngine {
// v2: Phase 4 bakes the major dev toolchains into the base Rust (rustc/cargo/rustup), Go, plus
// cmake/make/pkg-config + more common CLI tools and adds ~/.cargo/bin & ~/go/bin to the
// /etc/zshenv PATH, so every clone is born build-ready for JS/Python/Rust/Go/C++.
public static let macOSProvisioningRecipe = 2
// v3: disable window restoration (so clones don't reopen the base build's `nucleic-bootstrap.sh`
// Terminal window) and bake in a LaunchAgent that clears NotificationCenter at login + every 60s.
// v4: if a FULL Xcode (Xcode.app OR Xcode-beta.app) is baked into the base, finish its one-time
// setup during provisioning xcode-select it, accept the license, run `-runFirstLaunch`, and
// pre-download the Metal toolchain so clones don't pay first-launch + Metal on every run.
public static let macOSProvisioningRecipe = 4
/// The current Linux base **provisioning-recipe version**. Bump when the bundled Linux provisioning
/// assets change in a way that must re-provision an already-built base a new step in
+44 -21
View File
@@ -74,10 +74,11 @@ public actor MacVMEngine {
let storageRoot: URL
/// Live VMs keyed by logical name. Empty at launch (nothing survives the app process).
var live: [String: LiveVM] = [:]
/// Frozen screen (base64 JPEG) grabbed the instant each VM was suspended, keyed by name. A paused
/// Frozen screen (raw JPEG `Data`) grabbed the instant each VM was suspended, keyed by name. A paused
/// guest's CPU is stopped, so a fresh capture would hang the live monitor serves this still under
/// its "Paused" overlay instead. Cleared when the VM resumes or stops.
private var pauseFrames: [String: String] = [:]
/// its "Paused" overlay instead. Cleared when the VM resumes or stops. Stored as `Data` (the monitor's
/// fast path wants raw bytes); the base64 ``captureScreen`` overload encodes on demand.
private var pauseFrames: [String: Data] = [:]
/// In-flight boots keyed by name, so concurrent `ensureRunning` for the SAME name join the one
/// boot instead of racing into a second `VZVirtualMachine` on the same writable disk. Also counted
/// toward the concurrency ceiling so a burst of distinct-name boots can't overshoot it.
@@ -329,13 +330,13 @@ public actor MacVMEngine {
throw MacVMError.startFailed("configuration: \(error)")
}
// A host-side surface (framebuffer capture + HID) must bind a `VZVirtualMachineView`, which
// requires the VM to live on the main queue. Bind one for every VM (not just computer-use ones)
// so the monitor always captures from the host display driver hence main-queue whenever a
// surface host is present. Only a headless build with no surface host boots on a private queue.
// The VM always boots on its own dedicated background queue (see `MacVMInstance.init`); the
// main thread is never used for guest I/O, even for displayed VMs. A host-side surface
// (framebuffer capture + HID) is bound for every VM whenever a surface host is present, so the
// monitor can always capture from the host display driver this is independent of the VM's
// queue: the `VZVirtualMachineView` is touched on the main thread, the VM runs off it.
let willAttachSurface = surfaceHost != nil
let instance = MacVMInstance(
configuration: config, label: spec.name, mainQueue: willAttachSurface)
let instance = MacVMInstance(configuration: config, label: spec.name)
do {
try await instance.start()
} catch {
@@ -572,7 +573,7 @@ public actor MacVMEngine {
// live capture would hang the monitor needs this still to show under the "Paused" overlay.
// Best-effort; a miss just leaves the monitor on its last good frame. Skip if already paused
// (keep the frame we froze on the way in the first time).
if !entry.paused, let frame = await captureScreen(name: name) {
if !entry.paused, let frame = await captureScreenData(name: name) {
pauseFrames[name] = frame
}
let ok = await entry.instance.pause()
@@ -776,11 +777,20 @@ public actor MacVMEngine {
/// running or the framebuffer grab is empty/unavailable, so a passive monitor can poll it cheaply and
/// simply hold its last good frame on a miss (a macOS guest can transiently return a blank grab).
public func captureScreen(name: String) async -> String? {
await captureScreenData(name: name)?.base64EncodedString()
}
/// Raw JPEG `Data` of the named guest's current framebuffer the live **VM monitor**'s fast path,
/// which wants bytes it can decode straight into an image with no base64 round-trip. Same capture
/// rules as ``captureScreen``: serves the frozen still for a suspended guest, else grabs host-side
/// from the display driver; `nil` when the VM isn't running or the grab is empty.
public func captureScreenData(name: String) async -> Data? {
guard let entry = live[name] else { return nil }
// A suspended guest's CPU is frozen, so a live capture would hang: serve the still grabbed the
// moment it was paused what the monitor shows under its "Paused" overlay.
if entry.paused { return pauseFrames[name] }
return await captureSurfaceBase64(name: name)
guard hasCaptureSurface(name) else { return nil }
return await surfaceHost?.capture(name: name)
}
/// On app launch, reconcile on-disk clone bundles: keep only the active sessions' clones and drop
@@ -796,15 +806,24 @@ public actor MacVMEngine {
// MARK: - Background activity
private func syncBackgroundActivity() {
if !live.isEmpty, backgroundActivity == nil {
/// Hold the anti-nap assertion while **any** guest is running a per-session `live` VM *or* the
/// transient base-image VM booted for a build/provision/Recovery/app-injection pass (`baseIsBusy`).
/// The base-maintenance VM never enters `live`, so gating on `live` alone left the whole base
/// build/provision pass unprotected: an auto-rebuild that fires at launch (e.g. right after an
/// update, while the relaunched app is still in the background) would get App-Napped, throttling the
/// main-queue provisioning VM and the HID that drives it so the guest never reached a drivable
/// desktop and the build wedged at "Waiting for the guest desktop". Callers must re-run this whenever
/// `live` **or** a base-busy flag (`baseBuilding` / `baseAppInstalling` / `baseRecoveryActive`) changes.
func syncBackgroundActivity() {
let needed = !live.isEmpty || baseIsBusy
if needed, backgroundActivity == nil {
backgroundActivity = ProcessInfo.processInfo.beginActivity(
options: [
.userInitiatedAllowingIdleSystemSleep, .suddenTerminationDisabled,
.automaticTerminationDisabled,
],
reason: "Nucleic macOS VM running")
} else if live.isEmpty, let activity = backgroundActivity {
} else if !needed, let activity = backgroundActivity {
ProcessInfo.processInfo.endActivity(activity)
backgroundActivity = nil
}
@@ -1008,13 +1027,17 @@ final class MacVMInstance: NSObject, VZVirtualMachineDelegate, @unchecked Sendab
/// Fired (once) when the guest powers itself off or stops with an error.
private var onStop: (@Sendable () -> Void)? = nil
init(configuration: VZVirtualMachineConfiguration, label: String, mainQueue: Bool = false) {
// Computer-use VMs run on the MAIN queue so a `VZVirtualMachineView` can bind to them (the
// view + its VM must share the main thread). Safe: `init` is called from the engine actor,
// which never runs on main, so `main.sync` below can't deadlock. Exec-only VMs keep a
// dedicated background queue. macOS caps concurrent macOS guests at 2, so main-queue VMs are
// not a scalability concern.
let q = mainQueue ? DispatchQueue.main : DispatchQueue(label: "xyz.blakeslee.nucleic.macvm.\(label)")
init(configuration: VZVirtualMachineConfiguration, label: String) {
// EVERY VM runs on its own dedicated serial queue never the main queue so guest virtio
// device I/O never contends with the app's UI on the main thread. This includes VMs whose
// screen is displayed: a `VZVirtualMachineView` requires only that the *view* object be
// touched on the main thread (AppKit), NOT that the VM run on the main queue. The view is
// bound on `@MainActor` in `MacVMComputerSurface.attach` while the VM lives here on `queue`,
// exactly as UTM does. `qos: .userInitiated` keeps guest responsiveness high without the
// priority-inversion warnings that `.userInteractive` triggers against the main thread.
// `init` runs on the engine actor (never on this fresh queue), so the `q.sync` calls below
// which create the VM and set its delegate on its own queue, as VZ requires can't deadlock.
let q = DispatchQueue(label: "xyz.blakeslee.nucleic.macvm.\(label)", qos: .userInitiated)
self.queue = q
self.vm = q.sync { VZVirtualMachine(configuration: configuration, queue: q) }
super.init()
@@ -380,6 +380,12 @@ public actor MacVMManager {
await engine.captureScreen(name: name)
}
/// Raw JPEG `Data` of the named VM's screen for the live monitor's fast path (no base64 round-trip),
/// or `nil` when it isn't running / can't be captured. No lifecycle effect.
public func captureScreenData(name: String) async -> Data? {
await engine.captureScreenData(name: name)
}
/// Best-effort CPU/RAM sample of the named running VM for the Control panel's resource meters, or
/// `nil` when it isn't running. No lifecycle effect. Probes the guest over SSH, so callers should
/// refresh it off their critical path (see `AppStore.refreshMacVMUsageInBackground`).
@@ -45,6 +45,15 @@ extension MacVMSurfaceHost {
public func presentOperatorAssist(name: String, visible: Bool) async {}
}
extension Notification.Name {
/// Posted (on the main thread) when the user closes an on-screen surface window the base-build
/// diagnostic monitor or the operator-assist viewer directly (its close button) rather than via a
/// Hide control. The `object` is the surface `name` (a `String`), so a listener can match a specific
/// surface (e.g. the Settings "Show/Hide VM screen" toggle flips back to "Show" only for the
/// base-provision surface). The surface itself re-parks the window off-screen regardless.
public static let macVMObserverWindowClosed = Notification.Name("nucleic.macvm.observerWindowClosed")
}
/// A guest-space point (1920×1200 framebuffer coordinates).
public struct Point: Sendable, Equatable {
public let x: Int
+10 -5
View File
@@ -513,11 +513,16 @@ Lifecycle mirrors the container manager ([RUNTIME_ARCHITECTURE](RUNTIME_ARCHITEC
stale clone bundles, keeping only the active sessions'. Daemonless, so this is pure on-disk GC (no
running VM survives the process). Wired from `AppStore` at startup alongside the container
reconcile.
- **Anti-nap.** While at least one VM is live, `MacVMEngine` holds a `ProcessInfo.beginActivity`
assertion (`.userInitiatedAllowingIdleSystemSleep`, `.suddenTerminationDisabled`,
`.automaticTerminationDisabled`) so the app isn't App-Napped or auto-terminated out from under a
running guest (mirrors `ContainerEngine`'s assertion; the runtime is daemonless, so the app process
bounds every VM's life).
- **Anti-nap.** While at least one VM is live **or the base is busy** (a build / provision / Recovery /
app-injection pass — `baseIsBusy`), `MacVMEngine` holds a `ProcessInfo.beginActivity` assertion
(`.userInitiatedAllowingIdleSystemSleep`, `.suddenTerminationDisabled`, `.automaticTerminationDisabled`)
so the app isn't App-Napped or auto-terminated out from under a running guest (mirrors
`ContainerEngine`'s assertion; the runtime is daemonless, so the app process bounds every VM's life).
The base-maintenance VM never enters the `live` registry, so the assertion must also cover
`baseIsBusy` — otherwise an auto-rebuild that fires at launch (e.g. right after an update, while the
relaunched app is still backgrounded) would be App-Napped and its main-queue provisioning VM + HID
frozen, wedging the build at "Waiting for the guest desktop". `syncBackgroundActivity()` is therefore
re-run whenever `live` **or** a base-busy flag changes.
### 10.1 The concurrent-guest ceiling
@@ -93,29 +93,6 @@ struct SettingsView: View {
.id(pairedHostsToken)
}
Section {
if TailnetSupport.isBuiltIn {
if let status = store.tailnetStatus {
LabeledContent("Node", value: status)
}
if let loginURL = store.tailnetLoginURL {
Link(destination: loginURL) {
Label("Open Tailscale login", systemImage: "arrow.up.forward.app")
}
} else {
Text("No setup needed — when your Mac shares over Tailscale, this iPhone joins your tailnet on first connect and opens a browser login to approve itself.")
.font(.caption).foregroundStyle(.secondary)
}
} else {
Text("This build doesn't include Tailscale support.")
.foregroundStyle(.secondary)
}
} header: {
Text("Tailscale")
} footer: {
Text("")
}
Section("This device") {
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
LabeledContent("Key fingerprint", value: store.deviceFingerprint)
+20 -3
View File
@@ -55,10 +55,27 @@ if [ -n "${NUCLEIC_SCRATCH_PATH:-}" ]; then
exit 0
fi
case "$root" in
# Resolve symlinks before classifying. Inside the macOS guest the repo's ORIGINAL host
# path (/Users/…) is a symlink to the automounted virtiofs share (/Volumes/My Shared
# Files/…) — the engine re-creates it (MacVMEngine+HostPaths.swift, see MACOS_VM.md §6.1)
# so SwiftPM's baked-in /Users/… paths resolve in the guest. mac_vm_exec runs from that
# /Users path BY DEFAULT, so `$root` is that symlink and the bare "/Volumes/*" match below
# would miss it — leaving .build/ on the slow virtio-fs share, where SwiftPM's XCFramework
# extraction (Sparkle's binary target) fails outright because virtio-fs supports neither
# clonefile() nor the framework symlinks the unpack needs. Look THROUGH the link so a build
# launched from either path is recognized as being on the share.
resolved="$root"
if cd "$root" 2>/dev/null; then
resolved="$(pwd -P)"
fi
case "$resolved" in
/Volumes/*)
# On the VM share: redirect onto the guest's own fast, private disk. Hash the mount
# path so two shares mounted at different points get independent trees.
# On the VM share reached directly via the /Volumes automount or through the /Users
# symlink. Redirect onto the guest's own fast, private disk. Hash the ORIGINAL,
# unresolved `$root` (not `$resolved`): a build from the /Users path bakes /Users/… into
# its tree while one from the /Volumes path bakes /Volumes/…, so they must NOT share a
# scratch tree or they poison each other's absolute paths — the very thing this guards.
if command -v shasum >/dev/null 2>&1; then
tag="$(printf '%s' "$root" | shasum | cut -c1-12)"
else
+120
View File
@@ -325,6 +325,65 @@ else
echo " ⚠ could not disable com.apple.tipsd for uid $TIPS_UID (continuing)." >&2
fi
# ── Phase 5⅞: disable window restoration (don't reopen the build's Terminal in clones) ─────────────
# The base is built by DRIVING a Terminal window to run the host bootstrap (nucleic-bootstrap.sh, which
# runs THIS script); that window is still open when the build powers the guest off. macOS's "Resume"
# feature saves open windows at logout/shutdown and RE-OPENS them on the next login — so without this,
# every session clone boots with the leftover `nucleic-bootstrap.sh` Terminal window restored onto the
# very desktop we screenshot. Pin window restoration OFF (per the `agent` user, baked into the base) so
# clones boot to a clean desktop and no stale Terminal window lingers after the bootstrap completes:
# • TALLogoutSavesState=false → loginwindow doesn't save open windows at logout/shutdown
# • NSQuitAlwaysKeepsWindows=false → apps don't reopen their windows on relaunch (global + Terminal)
# Runs as `agent`, so these land in the auto-login user's domains that loginwindow/Terminal actually read.
echo "▸ [5⅞/8] Disabling window restoration (so clones don't reopen the build's Terminal window) …"
defaults write com.apple.loginwindow TALLogoutSavesState -bool false 2>/dev/null \
|| echo " ⚠ could not disable loginwindow state saving (continuing)." >&2
defaults write NSGlobalDomain NSQuitAlwaysKeepsWindows -bool false 2>/dev/null || true
defaults write com.apple.Terminal NSQuitAlwaysKeepsWindows -bool false 2>/dev/null || true
# Drop any Terminal window state already saved during this build, so nothing lingers to restore.
rm -rf "$AGENT_HOME/Library/Saved Application State/com.apple.Terminal.savedState" 2>/dev/null || true
echo " ✓ window restoration disabled for $AGENT_USER."
# ── Phase 5⅞+: sweep notification banners off the screenshotted desktop (login + every 60s) ────────
# On the auto-login desktop the host screenshots, notification banners slide in over the top-right and
# can obscure whatever the agent is looking at. Bake in a LaunchAgent that kills NotificationCenter at
# login and once a minute after: banners are cleared regularly, but the ~60s cadence still leaves a
# window for the agent to observe an intentional notification it just triggered before it's swept away.
# NotificationCenter relaunches on demand, so this only dismisses what's currently on screen — the same
# mechanism as the `mac_vm_clear_notifications` tool, just on a timer. An Aqua-session LaunchAgent (so
# it runs in the agent's GUI session and `killall` targets that session's NotificationCenter, no sudo
# needed); RunAtLoad fires it at boot, StartInterval repeats it every 60 seconds.
echo "▸ [5⅞+/8] Installing the NotificationCenter-clearing LaunchAgent (login + every 60s) …"
KILLNC_PLIST="/Library/LaunchAgents/xyz.blakeslee.nucleic.killnotificationcenter.plist"
sudo tee "$KILLNC_PLIST" >/dev/null <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>xyz.blakeslee.nucleic.killnotificationcenter</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/killall</string>
<string>NotificationCenter</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>60</integer>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>StandardOutPath</key>
<string>/tmp/nucleic-killnotificationcenter.log</string>
<key>StandardErrorPath</key>
<string>/tmp/nucleic-killnotificationcenter.log</string>
</dict>
</plist>
PLIST
sudo chown root:wheel "$KILLNC_PLIST"
sudo chmod 644 "$KILLNC_PLIST"
echo " ✓ LaunchAgent at $KILLNC_PLIST (Aqua, RunAtLoad + StartInterval 60)."
# ── Phase 6: warm the simulator + shut down clean ────────────────────────────────────────────────
# Pre-accept / warm the iOS simulator first-launch so the first real agent turn isn't slowed by it.
# Harmless (and skipped) if only the CLT are installed and no runtimes exist yet.
@@ -645,6 +704,67 @@ if [ -f "$PKG_INSTALLER" ]; then
/bin/bash "$PKG_INSTALLER" || echo " ⚠ common-package install reported errors (continuing)." >&2
fi
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# ── Phase 7⅞: FULL Xcode first-launch + Metal toolchain (bake into the base, not per-run) ───────────
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# If the operator baked a FULL Xcode into the base (staged as an "Included app" in Phase 7½, or dropped
# in by a common package), finish its one-time setup HERE so every disposable session clone is born
# build-ready. Without this, the first agent turn that builds a full-Xcode / Metal target pays for the
# first-launch component install AND the Metal toolchain download itself — on EVERY clone, since clones
# are thrown away. That's the "Xcode[-beta] needs its first-launch component install, then retry the
# Metal toolchain download" churn this phase eliminates.
#
# Runs AFTER Phase 7½/7¾ (that's when Xcode.app lands) and is fully guarded + idempotent: no full Xcode
# in /Applications ⇒ skip silently (CLT-only bases are unaffected). `run_timeout` bounds each step so a
# wedged download can't stall the base build; failures never abort provisioning.
echo "▸ [7⅞/8] Finalizing full Xcode (first-launch components + Metal toolchain), if present …"
# Locate a FULL Xcode by BUNDLE, not by a fixed name — it may be "Xcode.app" (stable) or "Xcode-beta.app"
# (beta channel), and the operator could stage either depending on their OS version/channel. Accept any
# /Applications/Xcode*.app that carries a real xcodebuild; prefer stable, then beta, then any other.
XCODE_APP=""
for cand in /Applications/Xcode.app /Applications/Xcode-beta.app /Applications/Xcode*.app; do
if [ -x "$cand/Contents/Developer/usr/bin/xcodebuild" ]; then XCODE_APP="$cand"; break; fi
done
if [ -z "$XCODE_APP" ]; then
echo " (no full Xcode in /Applications — skipping; the CLT-only base is unaffected.)"
else
XCODE_DEVDIR="$XCODE_APP/Contents/Developer"
echo " ▸ Using $XCODE_APP"
# Point the toolchain at full Xcode (overrides the CLT selected in Phase 3) so xcodebuild/xcrun and
# every agent shell resolve into it — and so first-launch/Metal act on THIS Xcode, not the CLT.
sudo /usr/bin/xcode-select -s "$XCODE_DEVDIR" 2>/dev/null \
&& echo " ✓ xcode-select → $XCODE_DEVDIR" \
|| echo " ⚠ could not xcode-select $XCODE_DEVDIR (continuing)." >&2
# Accept the license non-interactively (a fresh Xcode refuses to build until its license is accepted).
sudo "$XCODE_DEVDIR/usr/bin/xcodebuild" -license accept 2>/dev/null \
&& echo " ✓ Xcode license accepted" \
|| echo " ⚠ 'xcodebuild -license accept' reported an error (continuing)." >&2
# First-launch component install — the exact step the runtime message calls out. Installs Xcode's
# bundled packages (device support, dsym services, etc.). Bounded so it can't hang the base build.
echo " installing first-launch components (xcodebuild -runFirstLaunch) …"
run_timeout 1200 sudo "$XCODE_DEVDIR/usr/bin/xcodebuild" -runFirstLaunch 2>/dev/null \
&& echo " ✓ first-launch components installed" \
|| echo " ⚠ -runFirstLaunch reported an error/timeout (continuing)." >&2
# Pre-download the Metal toolchain. In Xcode 16+ it's a SEPARATE downloadable component
# (`-downloadComponent MetalToolchain`); older Xcodes bundle it and lack that verb. Probe first so a
# base that already has `metal` skips the (network) download and stays idempotent; then try the
# modern verb, falling back to the legacy `-downloadPlatform macOS`. Needs the guest's NAT network,
# which is up by this point (the common-packages phase above relies on it too).
if "$XCODE_DEVDIR/usr/bin/xcrun" --find metal >/dev/null 2>&1; then
echo " ✓ Metal toolchain already present (xcrun found 'metal') — skipping download."
else
echo " downloading the Metal toolchain …"
if run_timeout 1800 sudo "$XCODE_DEVDIR/usr/bin/xcodebuild" -downloadComponent MetalToolchain 2>/dev/null; then
echo " ✓ Metal toolchain downloaded (-downloadComponent)."
elif run_timeout 1800 sudo "$XCODE_DEVDIR/usr/bin/xcodebuild" -downloadPlatform macOS 2>/dev/null; then
echo " ✓ Metal toolchain downloaded (-downloadPlatform macOS fallback)."
else
echo " ⚠ Metal toolchain download failed/timed out — the first agent build may re-attempt it." >&2
fi
fi
fi
echo ""
echo "────────────────────────────────────────────────────────────────────────────────────────────────"
echo "✓ Provisioning complete. This guest is now Nucleic's golden base."