diff --git a/.claude/skills/swift/references/gotchas.md b/.claude/skills/swift/references/gotchas.md index bfd7a77693..572b7377fb 100644 --- a/.claude/skills/swift/references/gotchas.md +++ b/.claude/skills/swift/references/gotchas.md @@ -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 @@ -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 diff --git a/src/macos/GenesisKit/README.md b/src/macos/GenesisKit/README.md index 30ebc89db6..e81ad76ae3 100644 --- a/src/macos/GenesisKit/README.md +++ b/src/macos/GenesisKit/README.md @@ -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 } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift b/src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift new file mode 100644 index 0000000000..bf36ac5fe2 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift @@ -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.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.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.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.size) + guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &streams) == noErr else { return [] } + return streams + } + + private static func value(_ 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.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.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? + var size = UInt32(MemoryLayout?>.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() + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift b/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift index d8f1e2c20e..fac6cb231c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift @@ -4,11 +4,23 @@ import SwiftUI public struct NativeTimePoint: Identifiable, Equatable, Sendable { public var date: Date public var value: Double + /// The denominator behind a rate. Counts ignore it; averages preserve it when bins merge. + public var weight: Double public var id: Date { date } - public init(date: Date, value: Double) { self.date = date; self.value = value } + public init(date: Date, value: Double, weight: Double = 1) { self.date = date; self.value = value; self.weight = weight } } public enum NativeChartSampling { + static func zoomFraction(_ zoom: Double, minimum: Double, maximum: Double) -> Double { + guard maximum > minimum else { return 0 } + return min(1, max(0, log(max(1, zoom / minimum)) / log(maximum / minimum))) + } + + /// Grouped points may start before the picked interval, such as its first partial week. + static func fittedStart(_ start: Date, firstPoint: Date?) -> Date { + min(start, firstPoint ?? start) + } + public static func pannedPosition(origin: Date, translation: Double, width: Double, window: TimeInterval, domain: ClosedRange) -> Date { clampedPosition(origin.addingTimeInterval(-translation / max(1, width) * window), window: window, domain: domain) @@ -33,29 +45,76 @@ public enum NativeChartSampling { return .dateTime.hour().minute() } + /// How a bin combines the points that fall into it: counts add up, rates and shares average. + public enum Combine: Sendable { case sum, average } + + /// A calendar bin (a day or a week) follows the calendar: weeks start on the user's first weekday and a day + /// can be 23 or 25 hours long. Every other step is a fixed grid. + static func calendarComponent(for step: TimeInterval) -> Calendar.Component? { + step == 86400 ? .day : step == 604800 ? .weekOfYear : nil + } + + /// The width of one drawn bin: `step`, or a multiple of it when the span would need more than 1,200 bins. + public static func binInterval(start: Date, end: Date, step: TimeInterval) -> TimeInterval { + if calendarComponent(for: step) != nil { return step } + return max(step, ceil(end.timeIntervalSince(start) / (step * 1200)) * step) + } + + /// The span a bin covers, as a reader names it: "Mon, Oct 19 to Sun, Oct 25", "Mon, Oct 19, 2026", + /// "Oct 19, 14:00 to 15:00". + public static func spanText(_ date: Date, step: TimeInterval, calendar: Calendar = .current) -> String { + let day = Date.FormatStyle.dateTime.weekday(.abbreviated).month(.abbreviated).day() + if step >= 604800 { + let days = max(1, Int((step / 86400).rounded())) + let last = calendar.date(byAdding: .day, value: days - 1, to: date) ?? date + return "\(date.formatted(day)) to \(last.formatted(day))" + } + if step >= 86400 { return date.formatted(day.year()) } + let end = date.addingTimeInterval(step) + return "\(date.formatted(.dateTime.month(.abbreviated).day().hour().minute())) to \(end.formatted(.dateTime.hour().minute()))" + } + public static func bins(points: [NativeTimePoint], start: Date, end: Date, step: TimeInterval, - calendar: Calendar = .current) -> [NativeTimePoint] { + calendar: Calendar = .current, combine: Combine = .sum) -> [NativeTimePoint] { guard let first = points.first, end > start, step > 0 else { return [] } - if step == 86400 { - var day = calendar.startOfDay(for: max(first.date, start)) - let values = Dictionary(points.filter { $0.date >= day && $0.date < end }.map { - (calendar.startOfDay(for: $0.date), $0.value) - }, uniquingKeysWith: +) + if let component = calendarComponent(for: step) { + func key(_ date: Date) -> Date { + component == .day ? calendar.startOfDay(for: date) + : calendar.dateInterval(of: .weekOfYear, for: date)?.start ?? calendar.startOfDay(for: date) + } + var cursor = key(max(first.date, start)) + var sums: [Date: Double] = [:] + var counts: [Date: Double] = [:] + for point in points where point.date >= cursor && point.date < end { + let bin = key(point.date) + let weight = combine == .average ? max(0, point.weight) : 1 + sums[bin, default: 0] += point.value * weight + counts[bin, default: 0] += weight + } var result: [NativeTimePoint] = [] - while day < end && result.count < 1201 { - result.append(NativeTimePoint(date: day, value: values[day, default: 0])) - guard let next = calendar.date(byAdding: .day, value: 1, to: day), next > day else { break } - day = next + var visited = 0 + while cursor < end && visited < 1201 { + visited += 1 + if let count = counts[cursor], count > 0 { + let sum = sums[cursor, default: 0] + result.append(NativeTimePoint(date: cursor, value: combine == .sum ? sum : sum / count, + weight: combine == .average ? count : 1)) + } else if combine == .sum { + result.append(NativeTimePoint(date: cursor, value: 0)) + } + guard let next = calendar.date(byAdding: component, value: 1, to: cursor), next > cursor else { break } + cursor = next } return result } - let interval = max(step, ceil(end.timeIntervalSince(start) / (step * 1200)) * step) + let interval = binInterval(start: start, end: end, step: step) // Both bounds on the same interval grid, so a bin's time and count do not depend on where the view starts. let lower = max(floor(first.date.timeIntervalSince1970 / interval), floor(start.timeIntervalSince1970 / interval)) * interval let upper = end.timeIntervalSince1970 guard lower < upper else { return [] } let count = min(1201, Int(ceil((upper - lower) / interval))) var values = Array(repeating: 0.0, count: count) + var counts = Array(repeating: 0.0, count: count) var low = 0 var high = points.count while low < high { @@ -66,15 +125,29 @@ public enum NativeChartSampling { let time = point.date.timeIntervalSince1970 if time >= upper { break } let index = Int((time - lower) / interval) - if values.indices.contains(index) { values[index] += point.value } + if values.indices.contains(index) { + let weight = combine == .average ? max(0, point.weight) : 1 + values[index] += point.value * weight + counts[index] += weight + } + } + return values.indices.compactMap { index in + let date = Date(timeIntervalSince1970: lower + Double(index) * interval) + switch combine { + case .sum: return NativeTimePoint(date: date, value: values[index]) + case .average: + return counts[index] > 0 ? NativeTimePoint(date: date, value: values[index] / counts[index], weight: counts[index]) : nil + } } - return values.enumerated().map { NativeTimePoint( - date: Date(timeIntervalSince1970: lower + Double($0.offset) * interval), value: $0.element) } } } +/// A pannable, zoomable time series. Two ways to open: +/// - `rangeStart: nil`: on the latest `initialWindow`, zoomable from a quarter to 8 times that window. +/// - `rangeStart` set: fitted to the whole `rangeStart...end`; zoom goes from that whole range down to 5 bins, and +/// "Whole range" returns to it. public struct NativeTimeSeriesChart: View { - public enum Style: String, CaseIterable, Identifiable { + public enum Style: String, CaseIterable, Identifiable, Sendable { case line = "Line", area = "Area", bars = "Bars" public var id: Self { self } } @@ -83,7 +156,11 @@ public struct NativeTimeSeriesChart: View { private let initialWindow: TimeInterval private let valueLabel: String private let end: Date - @State private var style: Style = .area + private let rangeStart: Date? + private let combine: NativeChartSampling.Combine + private let valueFormat: ((Double) -> String)? + private let styleBinding: Binding