diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 39c017939a..c7495de7bf 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -2728,7 +2728,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // when silent they fall to a flat/short floor instead of // vanishing, so the menubar still looks "alive." let frameRate: CGFloat = 24 - let timer = Timer(timeInterval: TimeInterval(1.0 / frameRate), repeats: true) { [weak self] _ in + let timer = Timer(timeInterval: TimeInterval(1.0 / frameRate), repeats: true) { [weak self] timer in guard let self = self else { return } let now = CACurrentMediaTime() let tapeActive = self.menuBand.tape.state == .recording @@ -2744,6 +2744,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // synth remove its tap and suspend the otherwise-idle engine. self.setVisualizerWaveformCaptureEnabled(false) } + // Once all release tails are invisible, skip the RMS/filter work + // and back off this visual-only clock. Note handling never waits + // on it; updateIcon wakes it on the next input/state change. + let meterAtRest = !audioActive && !self.visualizerWaveformCaptureEnabled + && self.visualizerSmoothedLevel < 0.001 + && self.visualizerBars.allSatisfy { $0 < 0.001 } + && KeyboardIconRenderer.midiActivityFlash == 0 + && KeyboardIconRenderer.metronomeFlash == 0 + && !self.menuBand.percussionSplit + if meterAtRest { + timer.fireDate = Date(timeIntervalSinceNow: 0.25) + return + } // Two RMS sources, one consumer: // • While the user is recording (holding `), the bars // pulse with mic-input level — published by @@ -3647,6 +3660,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func updateIcon() { + if let timer = visualizerAnimTimer, + timer.fireDate.timeIntervalSinceNow > 1.0 / 24.0 { + timer.fireDate = Date() + } guard let button = statusItem.button else { return } // Keep the renderer's drum-zone coloring in sync with the live split. KeyboardIconRenderer.percussionLeftActive = menuBand.percussionLeft diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index f4ad347a94..5d77af0020 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -1333,6 +1333,7 @@ final class MenuBandPopoverViewController: NSViewController { // no one can see. Patch contributed by Esteban Uribe. override func viewWillDisappear() { super.viewWillDisappear() + instrumentCluster?.setPresented(false) transportTimer?.invalidate() transportTimer = nil } @@ -1426,6 +1427,7 @@ final class MenuBandPopoverViewController: NSViewController { override func viewDidAppear() { super.viewDidAppear() guard isViewLoaded, let menuBand = self.menuBand else { return } + instrumentCluster?.setPresented(true) // Drive the STOP transport while the popover is on screen. updateTransport() transportTimer?.invalidate() diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index e6137a3020..0fb0e0cf83 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -321,8 +321,12 @@ final class MenuBandSynth { /// so otherwise the engine would pause and the first hit would lag). var keepEngineWarm = false { didSet { - guard keepEngineWarm, started else { return } - _ = resumeAudioEngineIfNeeded() // warm it immediately on arm + guard keepEngineWarm != oldValue, started else { return } + if keepEngineWarm { + _ = resumeAudioEngineIfNeeded() // warm it immediately on arm + } else { + scheduleIdleSuspendIfNeeded() + } } } /// True once MIDISynth has loaded its bank, preloaded all programs, @@ -492,6 +496,11 @@ final class MenuBandSynth { if enabled && !inputMonitor.isAttached { NSLog("MenuBand: monitoring on but no input device could be opened") } + if enabled { + // Cold launch can now suspend the output engine. An explicit + // monitor request must wake it before its new source can render. + _ = resumeAudioEngineIfNeeded() + } inputMonitor.setEnabled(enabled && !monitorFocusMuted) // SampleVoice's own monitoring flag wakes its record engine (a second // duplex client on the interface) — only let it while the direct @@ -531,9 +540,12 @@ final class MenuBandSynth { /// explicit bounce) and hold `engineLock` where one is live. private func syncInputMonitorGraph() { NSLog("MenuBand monitor: sync — wanted=\(inputMonitoringWanted) eligible=\(duplexMonitorEligible()) attached=\(inputMonitor.isAttached) running=\(engine.isRunning)") - // Stay wired whenever an input device exists (see setInputMonitoringEnabled); - // "wanted" only decides audibility. - if duplexMonitorEligible() { + // A disabled monitor must not open the microphone at launch. Once + // explicitly opened, keep the connection on mute: reopening a live + // Scarlett stream can interrupt its output (see the toggle above). + if Self.shouldKeepMonitorOpen(wanted: inputMonitoringWanted, + attached: inputMonitor.isAttached, + eligible: duplexMonitorEligible()) { inputMonitor.preferredIOBufferFrames = Self.monitorIOBufferFrames if inputMonitor.isAttached { // Already wired: this is a device-change recovery or a @@ -557,6 +569,10 @@ final class MenuBandSynth { reconcileMicSource() } + static func shouldKeepMonitorOpen(wanted: Bool, attached: Bool, eligible: Bool) -> Bool { + eligible && (wanted || attached) + } + func ingestMonitoredInput(_ buffer: AVAudioPCMBuffer) { inputMonitor.ingest(buffer) } @@ -1537,7 +1553,8 @@ final class MenuBandSynth { } private func scheduleIdleSuspendIfNeeded() { - guard started, !waveformCaptureEnabled, activeNotes.isEmpty, + guard started, !waveformCaptureEnabled, waveformTapPinReasons.isEmpty, + !keepEngineWarm, activeNotes.isEmpty, !sampleRecordingActive, !inputMonitor.isAttached else { return } idleSuspendWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -1553,7 +1570,8 @@ final class MenuBandSynth { // A live mic monitor is never idle: pausing the engine here would // stop the voice and leave every player node facing a paused engine // (AVAudioPlayerNode.play() then throws — two crashes on 2026-09-25). - guard started, engine.isRunning, !waveformCaptureEnabled, activeNotes.isEmpty, + guard started, engine.isRunning, !waveformCaptureEnabled, + waveformTapPinReasons.isEmpty, activeNotes.isEmpty, !sampleRecordingActive, !keepEngineWarm, !inputMonitor.isAttached else { return } removeWaveformTapIfNeeded() engine.pause() diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift index 520292c54b..cfb31e0e68 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift @@ -106,6 +106,10 @@ final class CollapsedPianoWaveformView: NSView { arrowsCluster.setHighlight(direction: direction, on: on) } + func setPresented(_ presented: Bool) { + waveformStrip.isLive = presented + } + private static let arrowsRowHeight: CGFloat = 34 private static let modeRowHeight: CGFloat = 22 private static let edgePadding: CGFloat = 6 @@ -337,6 +341,9 @@ final class CollapsedPianoWaveformView: NSView { ]) keymapButton.toolTip = "Open the full-screen keymap (piano + QWERTY)" + // Panels are prebuilt while hidden. Their attached views must not + // acquire an audio-capture lease until their host presents them. + waveformStrip.isLive = false waveformStrip.menuBand = menuBand // Clicking the live scope blows it up to the full-screen LED wall — // the same display, nothing else on screen. Where no visualizer is diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformViewController.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformViewController.swift index b1e486ab80..0669b95988 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformViewController.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformViewController.swift @@ -161,7 +161,7 @@ final class PianoWaveformViewController: NSViewController { private func updatePresentationState() { expandedView.setPresented(isPresented && presentationMode == .expanded) - // collapsed view no longer hosts a live visualizer; nothing to gate. + collapsedView.setPresented(isPresented && presentationMode == .collapsed) let isExpanded = presentationMode == .expanded closeButton.isHidden = !isExpanded closeButtonGlassView?.isHidden = !isExpanded diff --git a/slab/menuband/Tests/MenuBandTests/MonitorIdleTests.swift b/slab/menuband/Tests/MenuBandTests/MonitorIdleTests.swift new file mode 100644 index 0000000000..9231c35968 --- /dev/null +++ b/slab/menuband/Tests/MenuBandTests/MonitorIdleTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import MenuBand + +final class MonitorIdleTests: XCTestCase { + func testDisabledMonitorDoesNotOpenMicOnLaunchOrDeviceArrival() { + for eligible in [false, true] { + XCTAssertFalse(MenuBandSynth.shouldKeepMonitorOpen( + wanted: false, attached: false, eligible: eligible)) + } + } + + func testExplicitMonitoringOpensAvailableInput() { + XCTAssertTrue(MenuBandSynth.shouldKeepMonitorOpen( + wanted: true, attached: false, eligible: true)) + } + + func testMutingAnEstablishedInterfaceDoesNotReopenUSBStreams() { + XCTAssertTrue(MenuBandSynth.shouldKeepMonitorOpen( + wanted: false, attached: true, eligible: true)) + } + + func testDisconnectedInputIsReleasedEvenWhenMonitoringIsWanted() { + XCTAssertFalse(MenuBandSynth.shouldKeepMonitorOpen( + wanted: true, attached: true, eligible: false)) + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 58d64be06d..c84eca97fc 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -786,25 +786,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // awaiting brightness and stale blink). When the polygon goes away // entirely, stop the timer and reset phases. if !state.claudeSessions.isEmpty || state.idleResting { + let interval: TimeInterval = state.idleResting ? 0.5 : 0.08 + if let timer = animTimer, timer.timeInterval != interval { + timer.invalidate() + animTimer = nil + } if animTimer == nil { - let t = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in + let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in guard let self = self else { return } + let phaseStep = CGFloat(interval / 0.08) // Idle rests at a gentle hue flow; active states cycle // faster (the awaiting throb reads off this phase). let rainbowStep: CGFloat = self.state.idleResting ? 0.006 : 0.025 - self.rainbowPhase = (self.rainbowPhase + rainbowStep).truncatingRemainder(dividingBy: 1.0) + self.rainbowPhase = (self.rainbowPhase + rainbowStep * phaseStep).truncatingRemainder(dividingBy: 1.0) if self.state.anyActive { let rotSpeed = 0.004 + 0.012 * CGFloat(self.state.awaitingCount) self.rotationPhase = (self.rotationPhase + rotSpeed) .truncatingRemainder(dividingBy: .pi * 2) } else if self.state.idleResting { // Slow spin — ~50 s per revolution. Enticing, not busy. - self.rotationPhase = (self.rotationPhase + 0.010) + self.rotationPhase = (self.rotationPhase + 0.010 * phaseStep) .truncatingRemainder(dividingBy: .pi * 2) } self.updateIcon() } RunLoop.main.add(t, forMode: .common) + t.tolerance = interval * 0.15 animTimer = t } } else if let t = animTimer { diff --git a/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift b/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift index 04d319f1bb..831db6123c 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift @@ -208,14 +208,14 @@ final class ResourceGraph: NSObject { private var hoverGlobalMonitor: Any? private var hoverLocalMonitor: Any? - private let sampleInterval: TimeInterval = 1.0 / 4.0 + private let sampleInterval: TimeInterval = 1.0 private let barSectionBaseWidth: CGFloat = 13.5 private let graphInset: CGFloat = 2 private let graphGap: CGFloat = 1 - private let historyLimit = 60 // 15 seconds at 4 Hz. - private let networkStride = 2 // Network reads at 2 Hz to calm short bursts. - private let gpuStride = 8 // IOKit once per two seconds. - private let diskStride = 40 // statfs once per ten seconds. + private let historyLimit = 15 // Keep the same 15-second window at 1 Hz. + private let networkStride = 1 // Network deltas cover a full second. + private let gpuStride = 2 // IOKit once per two seconds. + private let diskStride = 10 // statfs once per ten seconds. /// MenuBand scales its 13.5pt semitone keys with this same factor. private var displayScale: CGFloat { @@ -352,7 +352,6 @@ final class ResourceGraph: NSObject { button.contentTintColor = nil // The same local panel serves hover and click; there is deliberately no // second click-only menu or tooltip path. - guard tickCount == 1 || tickCount % 4 == 0 else { return } button.toolTip = nil refreshHoverCard() } diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift index 700f93aaed..ad12795387 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -400,6 +400,10 @@ struct StateSnapshot { /// prints — and swap comes from vm.swapusage; both are in-process /// sysctls. The hogs list is one `ps` fork (memory-sorted), which is /// in line with the ioreg/pmset/pgrep forks gather() already pays. + private static let hogCacheLock = NSLock() + private static var cachedHogs: [SystemHog] = [] + private static var hogsReadAt: TimeInterval = -.infinity + private static func readSystemStats() -> SystemStats { var stats = SystemStats() @@ -421,6 +425,15 @@ struct StateSnapshot { stats.loadAvg = loads[0] } + // The memory-ranked process submenu does not need a full process + // census on every fleet refresh. Pressure/load remain live above. + hogCacheLock.lock() + defer { hogCacheLock.unlock() } + let now = ProcessInfo.processInfo.systemUptime + if now - hogsReadAt < 30 { + stats.hogs = cachedHogs + return stats + } if let out = ShellRunner.output( "/bin/ps", args: ["axm", "-o", "rss=,pcpu=,pid=,comm="], timeout: 2) { for line in out.split(separator: "\n").prefix(6) { @@ -435,6 +448,8 @@ struct StateSnapshot { stats.hogs.append(SystemHog( pid: pid, rssMB: rssKB / 1024, cpu: cpu, name: name)) } + cachedHogs = stats.hogs + hogsReadAt = now } return stats } diff --git a/toolchain/macos/blueberry-wallpaper/README.md b/toolchain/macos/blueberry-wallpaper/README.md index f2b6162854..fab06bc531 100644 --- a/toolchain/macos/blueberry-wallpaper/README.md +++ b/toolchain/macos/blueberry-wallpaper/README.md @@ -5,13 +5,14 @@ Meshy v6 AC Pals model recovered for the Xbox native app: `xbox/assets/pals-mesh-nat-amethyst.glb`. The app bundles the original GLB and a SceneKit-readable USD conversion of the -same mesh. SceneKit renders that geometry live at each display's native backing -resolution. Varied-size Pals marks rotate independently while buoyant +same mesh. SceneKit renders that geometry at 30 FPS at each display's native +backing resolution, with 2× MSAA and two drawable buffers. Varied-size Pals marks rotate independently while buoyant acceleration and a gentle sway carry them upward. Every instance uses shared -live mesh data rather than a bitmap sprite; its triangle topology is drawn as a -translucent system-accent wireframe without surface textures or lighting. +live mesh data rather than a bitmap sprite. One imported prototype and its +geometry/materials are shared across displays and appearance rebuilds. Imported +texture maps are discarded; the field uses a single opaque accent material. -The model keeps its original amethyst textures over a blueberry field that +The accent-shaded model floats over a blueberry field that responds to macOS appearance. The window sits beneath Finder's desktop icons, ignores input, joins every Space, and pauses while the display sleeps. diff --git a/toolchain/macos/blueberry-wallpaper/Sources/main.swift b/toolchain/macos/blueberry-wallpaper/Sources/main.swift index dbd6c0f0df..e4f4fb669f 100644 --- a/toolchain/macos/blueberry-wallpaper/Sources/main.swift +++ b/toolchain/macos/blueberry-wallpaper/Sources/main.swift @@ -56,13 +56,9 @@ private enum Emblem { } private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { - private struct Placement { - let model: SCNNode - var x: CGFloat - var y: CGFloat - let width: CGFloat - let height: CGFloat - } + // SCNNode.clone shares geometry/materials. Keep one imported prototype + // across displays and appearance rebuilds, not one import per window. + private static var sharedPrototype: SCNNode? private static let stageHalfHeight: CGFloat = 2.35 // Tiny drifting glyphs: roughly quarter-icon to half-icon height on the @@ -141,8 +137,8 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { // instead of against a transparent surface composited later — the // latter left tinted alpha fringes along every silhouette. sceneView.backgroundColor = .black - sceneView.antialiasingMode = .multisampling4X - sceneView.preferredFramesPerSecond = 60 + sceneView.antialiasingMode = .multisampling2X + sceneView.preferredFramesPerSecond = 30 sceneView.rendersContinuously = true sceneView.isPlaying = true addSubview(sceneView) @@ -174,10 +170,14 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { super.viewDidMoveToWindow() updateAppearance(animated: false) guard let scale = window?.backingScaleFactor else { return } - // Render at no less than 2× and let the compositor downsample: on a - // 1× display 4× MSAA alone still shows stair-steps along the - // silhouettes, and supersampling on top of it smooths them out. - sceneView.layer?.contentsScale = max(scale, 2) + // Native backing resolution avoids quadrupling render-target memory + // on 1× external displays. MSAA still smooths the geometry edges. + sceneView.layer?.contentsScale = scale + // The slow desktop field needs only a front/back drawable pair. + // A third full-screen Retina IOSurface costs ~19 MB on Blueberry. + if let metalLayer = sceneView.layer as? CAMetalLayer { + metalLayer.maximumDrawableCount = 2 + } } /// Posted after the system appearance flips. macOS keeps a light/dark @@ -316,7 +316,17 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { private func loadModel() { let prototype: SCNNode - if let emblemPath = Emblem.path, !Emblem.isModel { + if let shared = Self.sharedPrototype { + prototype = shared + prototype.enumerateChildNodes { node, _ in + for material in node.geometry?.materials ?? [] { + if !materials.contains(where: { $0 === material }) { + materials.append(material) + } + } + } + applyAccentColor(animated: false) + } else if let emblemPath = Emblem.path, !Emblem.isModel { guard let emblem = emblemPrototype(path: emblemPath) else { fputs("could not load emblem image at \(emblemPath)\n", stderr) return @@ -348,16 +358,13 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { (lo.x + hi.x) * 0.5, (lo.y + hi.y) * 0.5, (lo.z + hi.z) * 0.5) + Self.sharedPrototype = prototype for spec in Self.specs { let model = prototype.clone() let scale = spec.scale * Self.markScale model.scale = SCNVector3(scale, scale, scale) model.eulerAngles = SCNVector3(-0.055, spec.phase * .pi * 2, 0) - let angle = (spec.reverse ? -1 : 1) * CGFloat.pi * 2 - let turn = SCNAction.rotateBy(x: 0, y: angle, z: 0, duration: spec.turnSeconds) - turn.timingMode = .linear - model.runAction(.repeatForever(turn), forKey: "fullResolutionTurn") scene.rootNode.addChildNode(model) models.append(model) } @@ -372,10 +379,12 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { // side deliberately overscan by almost half their width so the live // field reads full-bleed instead of as an inset/letterboxed stage. let padding: CGFloat = 0.13 - var candidates: [Placement] = [] let swell = 1 + beatPulse(now: Date().timeIntervalSince1970) for (model, spec) in zip(models, Self.specs) { + let turn = CGFloat(elapsed / spec.turnSeconds) * .pi * 2 + model.eulerAngles = SCNVector3(-0.055, + spec.phase * .pi * 2 + (spec.reverse ? -turn : turn), 0) let raw = CGFloat(elapsed / spec.riseSeconds) + spec.phase let progress = raw - floor(raw) let scale = spec.scale * Self.markScale @@ -393,16 +402,7 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { let overscanWidth = halfWidth + width * 0.45 let desiredX = spec.x * overscanWidth + sin(progress * .pi * 2 + spec.phase * .pi) * halfWidth * spec.sway - candidates.append(Placement( - model: model, x: desiredX, y: low + (high - low) * buoyant, - width: width, height: height)) - } - - // Do not solve collisions frame-by-frame: a change in vertical order - // makes that solver jump an entire mark in one frame. Overlap is part - // of the field; independent continuous paths keep every edge stable. - for placement in candidates { - placement.model.position = SCNVector3(placement.x, placement.y, 0) + model.position = SCNVector3(desiredX, low + (high - low) * buoyant, 0) } } @@ -433,25 +433,20 @@ private final class PalsWallpaperView: NSView, SCNSceneRendererDelegate { } private func applyOriginalGLBMaterials(to model: SCNNode) { + // The field is accent shaded, so retain none of the imported model's + // texture maps. All mesh instances and displays share this material. + let material = SCNMaterial() + material.lightingModel = .lambert + material.diffuse.contents = NSColor.controlAccentColor + material.metalness.contents = 0.0 + material.roughness.contents = 1.0 + material.specular.contents = NSColor.black model.enumerateChildNodes { node, _ in - for material in node.geometry?.materials ?? [] { - material.fillMode = .fill - material.lightingModel = .lambert - material.diffuse.contents = NSColor.controlAccentColor - material.emission.contents = nil - material.multiply.contents = nil - material.normal.contents = nil - material.metalness.contents = 0.0 - material.roughness.contents = 1.0 - material.specular.contents = NSColor.black - // Opaque surfaces avoid SceneKit's transparency sorting and - // the stippled/dancing silhouettes it produces at the crop. - material.transparency = 1.0 - if !materials.contains(where: { $0 === material }) { - materials.append(material) - } + if let geometry = node.geometry { + geometry.materials = [material] } } + materials = [material] applyAccentColor(animated: false) } diff --git a/toolchain/macos/blueberry-wallpaper/build.sh b/toolchain/macos/blueberry-wallpaper/build.sh index 7e5fa25404..ed78598369 100755 --- a/toolchain/macos/blueberry-wallpaper/build.sh +++ b/toolchain/macos/blueberry-wallpaper/build.sh @@ -28,6 +28,7 @@ rm "$MODEL_DIR/pals-mesh-nat-amethyst.usdc" cp "$PALS_GLB" "$APP/Contents/Resources/pals-mesh.glb" swiftc -O \ + -target "$(uname -m)-apple-macos14.0" \ -framework AppKit \ -framework QuartzCore \ -framework SceneKit \