Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/skills/swift/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,13 @@ Keep measured findings separate from hypotheses; faster CPU numbers alone do not
never deinitialises and fires every frame for the life of the process. Stop it wherever the owner is replaced (a
`didSet` on the property), and count live ones for a test (`EdgeFrameTicker.live`). Find them in a sample of the
idle app: `EdgeFrameTicker.step` under `CA::Display::DisplayLink` with nothing animating.
- **A posted click on a hidden test window never reaches a SwiftUI gesture.** The window is not key, so AppKit
treats the click as the first mouse and drops it before SwiftUI sees it: a plain `Button` and an `.onTapGesture` both
stayed silent, while a click on a `TextField` worked (it is an AppKit view, hit-tested directly), which made the
harness look fine. Making the test window key would take the user's keyboard. Host the view in an `NSHostingView`
subclass whose `acceptsFirstMouse` returns true (`FirstClickHost` in WidgetInboxTasksPolishTests); that is how a
key panel delivers it. Prove the harness with a control (a `Button` that must fire) before you call a dead click
a bug.

## Hangs that reached the user on 2026-10-23, and why they got through — 2026-10-23 21:13

Expand Down Expand Up @@ -481,6 +488,13 @@ Keep measured findings separate from hypotheses; faster CPU numbers alone do not
while the output device was slow, every typing pause, and a tap that waits delays every key in every app. Start it
on its own queue and play the first click when it runs; keep the synchronous start only for offline rendering in
tests (`isInManualRenderingMode`).
- **Moving a start off the main thread is not enough when the main thread then asks the same object.**
`AVAudioEngine.isRunning` takes the engine lock that `start()` holds on the other queue, so the first fix still
blocked the key tap for the whole start (21:14). Keep the state yourself (`running`, `starting`). The same holds
for `NSSound.play()`: it waits for the output device, 0.8 s in the widget's alert; play it from a queue.
- **The first read of `AVAudioEngine.mainMixerNode` creates the output unit and waits for CoreAudio too.** Clicky
wired its voices to the mixer in `init`, which cost 0.5 s on the main thread every time the settings face launched
(21:30). Create the nodes in `init`, attach and connect them on the start queue just before the first `start()`.

## A button that works once, and pictures that come out white — 2026-10-23 22:44

Expand Down
15 changes: 11 additions & 4 deletions src/macos/GenesisKit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1541,16 +1541,23 @@ InfoStrip(

#### `NoticePill`

A short status line: an icon, a message and an optional dim detail line. A close icon is always shown. A success notice calls `dismiss` six seconds after it appears (the timer restarts when `text` changes); an error stays until the person dismisses it. The text wraps rather than ending in an ellipsis, and it can be selected and copied.
A status line: an icon, a message, an optional dim detail line, the ways to fix what it reports, and the full message behind "Details". A success notice without fixes calls `dismiss` six seconds after it appears (the timer restarts when `text` changes); an error, a warning, or a notice with a fix stays until the person dismisses it or it no longer applies. The text wraps rather than ending in an ellipsis, and it can be selected and copied.

- `text: String`: the message.
- `detail: String? = nil`: a dimmer second line.
- `isError: Bool = false`: shows a red warning icon and keeps the pill on screen.
- `dismiss: () -> Void`: called by the close icon, and by the timer for a success notice. The host removes the pill in it.
- `more: String? = nil`: the full message (stderr, a stack, a path list), shown behind a "Details" disclosure.
- `tone: NoticePill.Tone`: `.success` (green check), `.warning` (amber) or `.error` (red). The older `isError: Bool` initializer maps to `.error` or `.success`.
- `fixes: [NoticeFix] = []`: one row per fix.
- `NoticeFix.button(_ label:symbol:confirm:perform:)`: the label says what a press does ("Quit the older Genesis (pid 1234)"). With `confirm:` (a `NoticeFix.Confirmation` with the question, an optional consequence and the confirming label) the notice asks inside itself before it acts, so the question also works in a panel that never becomes key.
- `NoticeFix.command(_ label:, _ command:)`: a command for the person's terminal; the notice shows it and copies it.
- `dismiss: (() -> Void)? = nil`: called by the close icon, and by the timer for a success notice. The host removes the pill in it.

```swift
if let notice {
NoticePill(text: notice.text, detail: notice.detail, isError: notice.isError) {
NoticePill(text: notice.text, detail: notice.detail, more: notice.stderr, tone: .warning, fixes: [
.button("Quit the older Genesis (pid \(pid))", confirm: .init("Quit it?", confirmLabel: "Quit")) { quit(pid) },
.command("Or from a terminal", "kill \(pid)"),
]) {
self.notice = nil
}
}
Expand Down
164 changes: 164 additions & 0 deletions src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import AVFoundation
import AudioToolbox
import CoreAudio
import Foundation

/// One audio output of this Mac, with the delay macOS reports for it.
public struct AudioOutputDevice: Identifiable, Hashable, Sendable {
public let id: AudioDeviceID
public let uid: String
public let name: String
public let transport: UInt32
/// Device latency + safety offset + stream latency + one IO buffer, as CoreAudio reports them, in seconds. An AirPlay
/// output (a HomePod) reports about 2 s; built-in speakers about 0.03 s (measured 2026-10-25).
public let delay: TimeInterval

public init(id: AudioDeviceID, uid: String, name: String, transport: UInt32, delay: TimeInterval) {
self.id = id
self.uid = uid
self.name = name
self.transport = transport
self.delay = delay
}

public var isVirtual: Bool {
transport == kAudioDeviceTransportTypeVirtual || transport == kAudioDeviceTransportTypeAggregate
|| transport == kAudioDeviceTransportTypeAutoAggregate
}

public var isBuiltIn: Bool { transport == kAudioDeviceTransportTypeBuiltIn }

/// "2.0 s late" or "27 ms late".
public var delayText: String { Self.delayText(delay) }

public static func delayText(_ seconds: TimeInterval) -> String {
seconds >= 1 ? String(format: "%.1f s late", seconds) : "\(Int((seconds * 1000).rounded())) ms late"
}
}

/// The Mac's audio outputs, read from CoreAudio: the list, the system output, a faster alternative, and pinning an
/// `AVAudioEngine` to one device. Clicky plays through it; any part that plays sound can.
public enum AudioOutputs {
/// Every device with at least one output stream.
public static func outputs() -> [AudioOutputDevice] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
let system = AudioObjectID(kAudioObjectSystemObject)
guard AudioObjectGetPropertyDataSize(system, &address, 0, nil, &size) == noErr else { return [] }
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(system, &address, 0, nil, &size, &ids) == noErr else { return [] }
return ids.compactMap(device(for:))
}

public static func systemDefaultID() -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var id = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id) == noErr,
id != 0 else { return nil }
return id
}

/// An output to offer instead of a late one: the built-in speakers, else the connected output with the least delay.
/// Virtual and aggregate devices are never offered: they may not reach a speaker.
public static func suggestion(instead late: AudioOutputDevice, outputs: [AudioOutputDevice],
limit: TimeInterval) -> AudioOutputDevice? {
let candidates = outputs.filter { $0.uid != late.uid && !$0.isVirtual && $0.delay < limit }
return candidates.first(where: \.isBuiltIn) ?? candidates.min { $0.delay < $1.delay }
}

/// Makes `engine` play on `device` from its next start (call it while the engine is stopped). Returns false when
/// CoreAudio refused or the engine renders offline.
@discardableResult
public static func pin(_ engine: AVAudioEngine, to device: AudioDeviceID) -> Bool {
guard !engine.isInManualRenderingMode, let unit = engine.outputNode.audioUnit else { return false }
var device = device
return AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device,
UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
}

// MARK: - CoreAudio

private static func device(for id: AudioDeviceID) -> AudioOutputDevice? {
let streams = outputStreams(id)
guard !streams.isEmpty, let uid = string(id, kAudioDevicePropertyDeviceUID) else { return nil }
let output = kAudioObjectPropertyScopeOutput
let global = kAudioObjectPropertyScopeGlobal
let rate = value(id, kAudioDevicePropertyNominalSampleRate, global, Float64(0)) ?? 0
let frames = (value(id, kAudioDevicePropertyLatency, output, UInt32(0)) ?? 0)
+ (value(id, kAudioDevicePropertySafetyOffset, output, UInt32(0)) ?? 0)
+ (value(id, kAudioDevicePropertyBufferFrameSize, global, UInt32(0)) ?? 0)
+ (value(streams[0], kAudioStreamPropertyLatency, global, UInt32(0)) ?? 0)
return AudioOutputDevice(
id: id, uid: uid, name: string(id, kAudioDevicePropertyDeviceNameCFString) ?? "Output",
transport: value(id, kAudioDevicePropertyTransportType, global, UInt32(0)) ?? 0,
delay: rate > 0 ? Double(frames) / rate : 0)
}

private static func outputStreams(_ id: AudioDeviceID) -> [AudioStreamID] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreams, mScope: kAudioObjectPropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0 else { return [] }
var streams = [AudioStreamID](repeating: 0, count: Int(size) / MemoryLayout<AudioStreamID>.size)
guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &streams) == noErr else { return [] }
return streams
}

private static func value<T: BinaryInteger & Sendable>(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector,
_ scope: AudioObjectPropertyScope, _ initial: T) -> T? {
var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: kAudioObjectPropertyElementMain)
var result = initial
var size = UInt32(MemoryLayout<T>.size)
return AudioObjectGetPropertyData(id, &address, 0, nil, &size, &result) == noErr ? result : nil
}

private static func value(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector,
_ scope: AudioObjectPropertyScope, _ initial: Float64) -> Float64? {
var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: kAudioObjectPropertyElementMain)
var result = initial
var size = UInt32(MemoryLayout<Float64>.size)
return AudioObjectGetPropertyData(id, &address, 0, nil, &size, &result) == noErr ? result : nil
}

private static func string(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
var text: Unmanaged<CFString>?
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &text) == noErr, let text else { return nil }
return text.takeRetainedValue() as String
}
}

/// Calls `changed` on the main queue when the system output or the list of devices changes.
@MainActor
public final class AudioOutputMonitor {
private var listeners: [(AudioObjectPropertyAddress, AudioObjectPropertyListenerBlock)] = []

public init(changed: @escaping @MainActor () -> Void) {
for selector in [kAudioHardwarePropertyDefaultOutputDevice, kAudioHardwarePropertyDevices] {
var address = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
let block: AudioObjectPropertyListenerBlock = { _, _ in
MainActor.assumeIsolated { changed() }
}
if AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, block) == noErr {
listeners.append((address, block))
}
}
}

public func stop() {
for (address, block) in listeners {
var address = address
AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, block)
}
listeners.removeAll()
}
}
Loading
Loading