Merge branch 'dev' into canary
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user