From f3d7931aa310d9ef6c6cee66fbe1a006256f1aa0 Mon Sep 17 00:00:00 2001 From: ealtun21 Date: Mon, 12 Oct 2026 09:09:32 +0300 Subject: [PATCH 1/2] fix(macos): rebind the tap when the default output moves under a pinned output, draw the tray template from the glyph, quiet the tap diagnostics Co-Authored-By: Claude Sonnet 5.5 --- .../resonance-daemon/src/audio/coreaudio.rs | 47 ++++++++++++++++--- .../resonance-daemon/src/audio/system_tap.rs | 19 ++++++++ crates/resonance-tray/src/icons.rs | 31 +++++++----- 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/crates/resonance-daemon/src/audio/coreaudio.rs b/crates/resonance-daemon/src/audio/coreaudio.rs index a021618..2ff249d 100644 --- a/crates/resonance-daemon/src/audio/coreaudio.rs +++ b/crates/resonance-daemon/src/audio/coreaudio.rs @@ -27,8 +27,8 @@ use super::hal_input::HalInputStream; use super::system_tap::{ - SystemAudioTap, TAP_DEVICE_NAME, default_output_rate, default_output_uid, - set_aggregate_nominal_rate, + SystemAudioTap, TAP_DEVICE_NAME, audio_capture_allowed, default_output_rate, + default_output_uid, set_aggregate_nominal_rate, }; use super::{CHANNELS, apply_command}; use crate::meters::{AtomicMeters, Sample, peak_rms, peak_rms_f32}; @@ -45,7 +45,7 @@ use std::sync::{ }; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; /// Ring-buffer capacity (interleaved stereo f32 samples) between input and /// output streams. ~340 ms at 48 kHz stereo — generous headroom for jitter @@ -316,6 +316,12 @@ struct SharedRt { meters: Arc, } +/// A system tap is bound to one device's UID; it is stale when the OS default moved elsewhere. +/// Per-app mixer taps have no UID (empty) and never go stale this way. +fn tap_follows_stale_default(bound_uid: &str, default_uid: Option<&str>) -> bool { + !bound_uid.is_empty() && default_uid.is_some_and(|d| d != bound_uid) +} + enum StreamExit { /// The supervisor noticed a `route_rx` update; rebuild against the new /// preferred output (returned in the variant). @@ -465,9 +471,16 @@ fn run_streams( diag_poll = Instant::now(); let cb = input_callbacks.load(Ordering::Relaxed); let nz = input_nonzero.load(Ordering::Relaxed); - info!("HAL tap IOProc: {cb} callbacks, {nz} with audio"); - // After 5 s with no non-zero blocks, surface a one-shot TCC hint. - if !warned_silent && cb > 50 && nz == 0 && started.elapsed() >= Duration::from_secs(5) { + // Every 2 s forever into an unrotated launchd log (about 10 MB/day), so debug only. + debug!("HAL tap IOProc: {cb} callbacks, {nz} with audio"); + // After 5 s with no non-zero blocks, surface a one-shot TCC hint, unless the grant + // is known good (then the system is simply silent). + if !warned_silent + && !audio_capture_allowed() + && cb > 50 + && nz == 0 + && started.elapsed() >= Duration::from_secs(5) + { warn!( "tap IOProc has fired {cb} times but every block was silent — \ macOS is most likely refusing system audio capture. Open the \ @@ -490,6 +503,14 @@ fn run_streams( return Ok(StreamExit::Ended); } } + } else if tap_follows_stale_default( + tap.device_uid(), + default_output_uid().ok().as_deref(), + ) { + // Pinned output: the tap still captures whatever the OS default is, so a + // default switch moves app audio to a device this tap no longer hears. + info!("CoreAudio: default output changed under a pinned output; rebinding the tap"); + return Ok(StreamExit::Ended); } // Follow a sample-rate change on the SAME device (Audio MIDI Setup // change, BT codec renegotiation). cpal's default config reflects @@ -801,6 +822,20 @@ fn publish_sinks( mod tests { use super::*; + #[test] + fn tap_goes_stale_only_when_the_default_moves_off_its_device() { + assert!(!tap_follows_stale_default("A", Some("A"))); + assert!(tap_follows_stale_default("A", Some("B"))); + assert!( + !tap_follows_stale_default("A", None), + "no default yet: keep the tap" + ); + assert!( + !tap_follows_stale_default("", Some("B")), + "per-app mixer has no UID" + ); + } + // Compile-time invariants on the buffer constants — the audio thread // depends on these holding, so we fail the build (not the test run) if // someone shrinks them. diff --git a/crates/resonance-daemon/src/audio/system_tap.rs b/crates/resonance-daemon/src/audio/system_tap.rs index 320afbd..4edf63c 100644 --- a/crates/resonance-daemon/src/audio/system_tap.rs +++ b/crates/resonance-daemon/src/audio/system_tap.rs @@ -82,6 +82,8 @@ pub struct SystemAudioTap { tap_ids: Vec, aggregate_id: AudioObjectID, native_rate: f64, + /// UID of the output device a system tap is bound to; empty for the per-app mixer. + device_uid: String, } impl SystemAudioTap { @@ -217,6 +219,7 @@ impl SystemAudioTap { tap_ids: vec![tap_id], aggregate_id: agg_id, native_rate, + device_uid: device_uid.to_owned(), }) } @@ -322,6 +325,7 @@ impl SystemAudioTap { tap_ids, aggregate_id: agg_id, native_rate, + device_uid: String::new(), }, keys, )) @@ -349,6 +353,11 @@ impl SystemAudioTap { pub fn native_rate(&self) -> f64 { self.native_rate } + + /// UID of the output device this tap captures (empty for the per-app mixer). + pub fn device_uid(&self) -> &str { + &self.device_uid + } } impl Drop for SystemAudioTap { @@ -822,6 +831,15 @@ fn build_aggregate_dict( use objc2_core_foundation::CFString as TccCFString; use std::os::raw::c_int; +/// `TCCAccessPreflight` result at the last tap creation; -1 until probed. +static TCC_PREFLIGHT: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); + +/// True once the audio-capture grant was seen as allowed: a silent tap then means silence +/// in the system mix, not a refused capture. +pub fn audio_capture_allowed() -> bool { + TCC_PREFLIGHT.load(std::sync::atomic::Ordering::Relaxed) == 0 +} + type TccPreflightFn = unsafe extern "C" fn(*const TccCFString, *const c_void) -> c_int; /// Best-effort preflight of the audio-capture TCC service so we can LOG @@ -847,6 +865,7 @@ fn request_audio_capture_permission() { let preflight: TccPreflightFn = std::mem::transmute(pre); let service = TccCFString::from_static_str("kTCCServiceAudioCapture"); let status = preflight(std::ptr::from_ref(&*service), std::ptr::null()); + TCC_PREFLIGHT.store(status, std::sync::atomic::Ordering::Relaxed); match status { 0 => info!("kTCCServiceAudioCapture: ALLOWED (tap will deliver audio)"), 1 => warn!( diff --git a/crates/resonance-tray/src/icons.rs b/crates/resonance-tray/src/icons.rs index 7a07778..38ff659 100644 --- a/crates/resonance-tray/src/icons.rs +++ b/crates/resonance-tray/src/icons.rs @@ -48,14 +48,21 @@ pub fn bypassed() -> IconRgba { icon } -/// macOS template image: pure black, original alpha — the OS tints it for the -/// current menu-bar appearance. macOS-only: the desktop backend is the sole -/// caller, and only under `cfg(target_os = "macos")`. -#[cfg(target_os = "macos")] +/// macOS template image: pure black, and the *glyph* as alpha — the OS tints it +/// for the current menu-bar appearance. The committed PNG is an opaque dark tile +/// with a bright glyph, so the tile's own alpha (83 % opaque) would render as a +/// solid black square; instead alpha = tile alpha x smoothstep(luma), which keeps +/// the bars and curve and drops the dark tile. macOS-only: the desktop backend is +/// the sole caller (tests also run it on every platform). +#[cfg(any(target_os = "macos", test))] #[must_use] pub fn template() -> IconRgba { let mut icon = decode(); for px in icon.rgba.chunks_exact_mut(4) { + let luma = (0.299 * f32::from(px[0]) + 0.587 * f32::from(px[1]) + 0.114 * f32::from(px[2])) + / 255.0; + let t = ((luma - 0.2) / 0.18).clamp(0.0, 1.0); + px[3] = (f32::from(px[3]) * t * t * (3.0 - 2.0 * t)).round() as u8; px[0] = 0; px[1] = 0; px[2] = 0; @@ -92,17 +99,19 @@ mod tests { assert_eq!(a_alpha, b_alpha, "shape (alpha) unchanged"); } - #[cfg(target_os = "macos")] #[test] - fn template_zeroes_color_but_preserves_alpha() { - let plain = decode(); + fn template_is_black_glyph_not_a_solid_tile() { let t = template(); - assert_eq!((plain.width, plain.height), (t.width, t.height)); + let n = t.rgba.len() / 4; for px in t.rgba.chunks_exact(4) { assert_eq!(&px[..3], &[0, 0, 0], "template color is pure black"); } - let plain_alpha: Vec = plain.rgba.iter().skip(3).step_by(4).copied().collect(); - let t_alpha: Vec = t.rgba.iter().skip(3).step_by(4).copied().collect(); - assert_eq!(plain_alpha, t_alpha, "shape (alpha) unchanged"); + let opaque = t.rgba.chunks_exact(4).filter(|p| p[3] > 128).count(); + // Measured on the guest's menu bar: the old alpha-preserving template was an 83 % + // opaque solid square. The glyph alone is about 21 %. + assert!( + (n / 10..n * 4 / 10).contains(&opaque), + "glyph coverage {opaque}/{n} should be a shape, not a filled tile" + ); } } From 0e4a98623c328d394919f582a193abc39e77750d Mon Sep 17 00:00:00 2001 From: ealtun21 Date: Mon, 12 Oct 2026 09:09:32 +0300 Subject: [PATCH 2/2] test(macos): drive the guest desktop and run the daemon lifecycle matrix live Co-Authored-By: Claude Sonnet 5.5 --- contrib/e2e/macos/README.md | 43 +++++++ contrib/e2e/macos/lifecycle/build-app.sh | 12 ++ contrib/e2e/macos/lifecycle/gui-run.sh | 33 ++++++ contrib/e2e/macos/lifecycle/lifecycle.sh | 107 ++++++++++++++++++ contrib/e2e/macos/vmctl.sh | 53 +++++++++ contrib/e2e/macos/vmgui.py | 94 +++++++++++++++ .../2026-10-06-e2e-audio-test-infra-design.md | 48 ++++++++ 7 files changed, 390 insertions(+) create mode 100755 contrib/e2e/macos/lifecycle/build-app.sh create mode 100755 contrib/e2e/macos/lifecycle/gui-run.sh create mode 100755 contrib/e2e/macos/lifecycle/lifecycle.sh create mode 100755 contrib/e2e/macos/vmctl.sh create mode 100644 contrib/e2e/macos/vmgui.py diff --git a/contrib/e2e/macos/README.md b/contrib/e2e/macos/README.md index 8020d83..8fd5f8b 100644 --- a/contrib/e2e/macos/README.md +++ b/contrib/e2e/macos/README.md @@ -38,6 +38,49 @@ The result goes to a *different* BlackHole so the unprocessed audio is never in so the gate is wide (30 % or 30 ms); negative values mean the daemon path is shorter than the BlackHole loopback reference, which carries its own safety offset. Use the baseline, not the sign. +## Driving the guest desktop (lifecycle and consent work) + +`vmctl.sh` is the host-side handle on a throwaway overlay in `$RESONANCE_E2E_HOME/macos/lifecycle` (own ssh +port 22722, so it runs beside `cargo xtask e2e`, which picks free ports and its own overlays): + + contrib/e2e/macos/vmctl.sh reset # fresh overlay of the base image (discards guest changes) + contrib/e2e/macos/vmctl.sh boot # headless, plus a QMP socket for input and screenshots + contrib/e2e/macos/vmctl.sh ssh 'cmd' # shell as e2e; `script` takes a script on stdin + contrib/e2e/macos/vmctl.sh shot [png] # 1920x1080 screendump of the console (default lifecycle/shot.png) + contrib/e2e/macos/vmctl.sh click X Y [N] [right] # pixels of that screenshot + contrib/e2e/macos/vmctl.sh type 'text' / key ret meta_l+spc + contrib/e2e/macos/vmctl.sh lifecycle # sync, release-build the .app, run the lifecycle matrix + contrib/e2e/macos/vmctl.sh kill + +What each piece needed, and what works (all measured on macOS 15.8.1, quickemu, 16 vCPU): + +- **Screen and input**: QMP `screendump` and `input-send-event` (`vmgui.py`; HMP `mouse_move` does not move + the pointer). The earlier "black screen with a cursor" was the lock screen: auto-login runs, then macOS + locks. Typing the password (`e2etest`) through `vmctl.sh type` / `key ret` unlocks it. + A "disk you attached was not readable" dialog from quickemu's extra drive is harmless (Ignore). +- **Running things in the GUI session**: ssh sessions have no audio or TCC context, so audio work goes through + a throwaway LaunchAgent (`lifecycle/gui-run.sh 'cmd'`), which makes the process a child of launchd in + `gui/501`, the same attribution as the product's own daemon agent (older notes found `launchctl asuser` + from ssh keeps sshd as the responsible process; not re-measured here). +- **Consent dialogs**: the first start of a binary whose signing requirement has no grant raises two dialogs, + "access the microphone" then "Record Your System Audio". Both are clicked through QMP: with the guest at + 1920x1080 the Allow buttons are at (1018, 393) and (1018, 407). Until they are answered `coreaudiod` blocks + the daemon's tap setup after "Process tap ready", while IPC, status and `daemon stop` keep working. + `TCC.db` is readable with `sudo sqlite3` (SIP filesystem protection is off in the guest) but clicking the + dialog is simpler than writing a `csreq`. +- **Grants are one row per bundle id and follow the last signature**: after answering the dialogs for an + ad-hoc build, the cert-signed build prompted again (and the reverse). Do not mix identities in one overlay; + `reset` to get back to the image's grant. +- **Tray**: the menu opens on a *right* click on the menu-bar icon (left click launches the GUI), then the + items are plain clicks (Power, Daemon > Start / Stop / Restart worked end to end). +- **Not drivable**: the egui GUI. The guest has no Metal adapter, so `resonance-gui` exits at start with + `RequestAdapterError(NotFound)`; the tray's "Open UI" cannot be exercised either. The TUI was not driven. + +`lifecycle/lifecycle.sh BUNDLE [UPGRADE_BUNDLE]` is the matrix `vmctl.sh lifecycle` runs: install, launchd agent, +`resonance verify` through the live tap, `kill -9` recovery, stop/start, rate changes (44.1/96/48 kHz), a pinned +output with the OS default moved, upgrade over a running daemon, and uninstall cleanliness. It needs a full +release build of the app (about 12 minutes cold) and a granted identity, so it is not part of the quick tier. + ## The base image (one-time, partly manual) Built by hand once (recipe: `docs/superpowers/spikes/2026-10-07-macos-vm/`), then `provision.sh` over ssh: diff --git a/contrib/e2e/macos/lifecycle/build-app.sh b/contrib/e2e/macos/lifecycle/build-app.sh new file mode 100755 index 0000000..f51f9d4 --- /dev/null +++ b/contrib/e2e/macos/lifecycle/build-app.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Guest side: release-build the full app bundle with the product's own build-app.sh, signed with the +# image's stable identity, into ~/build/Resonance.app (nothing is installed yet). Log: ~/build-app.log. +set -eu +export PATH="$HOME/.cargo/bin:$PATH" +K=$HOME/Library/Keychains/resonance-signing.keychain-db +security unlock-keychain -p resonance "$K" +security list-keychains -d user -s "$K" "$HOME/Library/Keychains/login.keychain-db" +cd "$HOME/resonance" +APP_OUT="$HOME/build/Resonance.app" INSTALL_PATH=/Applications/Resonance.app CLI_DIR="$HOME/.local/bin" \ + bash contrib/macos/build-app.sh +echo BUILD-DONE diff --git a/contrib/e2e/macos/lifecycle/gui-run.sh b/contrib/e2e/macos/lifecycle/gui-run.sh new file mode 100755 index 0000000..babeba7 --- /dev/null +++ b/contrib/e2e/macos/lifecycle/gui-run.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Guest side: run a command line inside the logged-in GUI session (audio + TCC work there, not over +# ssh), through a throwaway LaunchAgent, and print its output and exit status. +# gui-run.sh [-t SECS] 'command line' +# The process is a child of launchd in gui/, so TCC attributes it to itself (like the product's +# own daemon agent), never to sshd. +set -u +T=120 +[ "${1:-}" = -t ] && { T=$2; shift 2; } +uid=$(id -u) +L=com.lc.exec +OUT=$HOME/.lc-exec.out +RC=$HOME/.lc-exec.rc +rm -f "$OUT" "$RC" +printf '#!/bin/bash\nexport PATH=$HOME/.local/bin:$HOME/.cargo/bin:$PATH\n%s\necho $? > %s\n' "$1" "$RC" >"$HOME/.lc-exec.sh" +cat >"$HOME/.lc-exec.plist" < + + +Label$L +ProgramArguments/bin/bash$HOME/.lc-exec.sh +StandardOutPath$OUTStandardErrorPath$OUT +RunAtLoad +EOF +launchctl bootout "gui/$uid/$L" 2>/dev/null +launchctl bootstrap "gui/$uid" "$HOME/.lc-exec.plist" || exit 99 +launchctl kickstart "gui/$uid/$L" || exit 98 +end=$((SECONDS + T)) +while [ ! -f "$RC" ] && [ $SECONDS -lt $end ]; do sleep 1; done +launchctl bootout "gui/$uid/$L" 2>/dev/null +cat "$OUT" 2>/dev/null +[ -f "$RC" ] || { echo "gui-run: timed out after ${T}s" >&2; exit 124; } +exit "$(cat "$RC")" diff --git a/contrib/e2e/macos/lifecycle/lifecycle.sh b/contrib/e2e/macos/lifecycle/lifecycle.sh new file mode 100755 index 0000000..e9e2207 --- /dev/null +++ b/contrib/e2e/macos/lifecycle/lifecycle.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Guest side: product lifecycle matrix against a real install, judged by `resonance verify` +# (pitch + response of real tones through the live path) and the daemon's own meters. +# lifecycle.sh BUNDLE [UPGRADE_BUNDLE] +# BUNDLE is a signed Resonance.app built by build-app.sh (TCC must already be granted to its +# signing identity, as in the e2e base image). UPGRADE_BUNDLE (a second build, same identity) +# adds the "replace under a running daemon" step. Needs BlackHole 2ch/16ch and the audiodev +# helper; exits non-zero on any failed check. ~3 minutes, run from `vmctl.sh script`. +set -u +BUNDLE=${1:?bundle} +UPGRADE=${2:-} +APP=/Applications/Resonance.app +export PATH=$HOME/.local/bin:$PATH +HERE=$(cd "$(dirname "$0")" && pwd) +GUI="bash $HERE/gui-run.sh" +AUDIODEV=${AUDIODEV:-$HOME/e2e-bin/audiodev} +LOG=$HOME/Library/Logs/resonance/resonanced.out.log +fail=0 +ok() { echo "PASS $1"; } +bad() { echo "FAIL $1"; fail=1; } +check() { local what=$1; shift; if "$@" >/dev/null 2>&1; then ok "$what"; else bad "$what"; fi; } +now() { python3 -c 'import time;print(time.time())'; } +reach() { resonance status >/dev/null 2>&1; } +wait_reach() { for _ in $(seq 1 $((${1:-6} * 10))); do reach && return 0; sleep 0.1; done; return 1; } +level() { resonance status 2>/dev/null | sed -n 's/^levels in \(.*\) dB out.*/\1/p'; } +# the pilot loops in the GUI session until stopped +tone_on() { + python3 - <<'E' +import math, struct, wave +w = wave.open('/tmp/lc-1k.wav', 'wb'); w.setnchannels(2); w.setsampwidth(2); w.setframerate(48000) +s = [int(8000 * math.sin(2 * math.pi * 1000 * i / 48000)) for i in range(48000 * 30)] +w.writeframes(b''.join(struct.pack('/dev/null + cat >/tmp/lc-tone.plist <Labelcom.lc.tone +ProgramArguments/bin/bash-cwhile :; do /usr/bin/afplay /tmp/lc-1k.wav; done +RunAtLoad +E + launchctl bootstrap gui/$(id -u) /tmp/lc-tone.plist +} +tone_off() { launchctl bootout gui/$(id -u)/com.lc.tone 2>/dev/null; } +audible() { # level above -60 dB within $1 seconds + for _ in $(seq 1 $(($1 * 5))); do l=$(level); v=${l%%.*}; [ -n "$v" ] && [ "$v" != "-inf" ] && [ "$v" -gt -60 ] && return 0; sleep 0.2; done; return 1 +} +verify() { $GUI -t 90 "resonance verify --freqs 100,1000,6000" >/tmp/lc-verify.out 2>&1; local rc=$?; tail -3 /tmp/lc-verify.out | sed 's/^/ /'; return $rc; } + +echo "== install" +resonance daemon uninstall >/dev/null 2>&1; pkill -x resonance-tray 2>/dev/null +sudo rm -rf $APP && sudo cp -R "$BUNDLE" $APP && sudo chown -R "$USER" $APP +check "bundle signature is valid" codesign --verify --deep --strict $APP +mkdir -p ~/.local/bin; ln -sf $APP/Contents/MacOS/resonance ~/.local/bin/resonance +$AUDIODEV default "BlackHole 2ch"; $AUDIODEV rate "BlackHole 2ch" 48000 >/dev/null +resonance daemon enable >/dev/null +check "launchd agent up and serving within 6 s" wait_reach 6 +check "plist is RunAtLoad and the agent is loaded" bash -c 'grep -A1 RunAtLoad ~/Library/LaunchAgents/com.ealtun21.resonanced.plist | grep -q true && launchctl print gui/$(id -u)/com.ealtun21.resonanced >/dev/null' +resonance output "BlackHole 16ch" >/dev/null; sleep 3 +check "tap delivers audio: verify PASS" verify + +echo "== crash recovery" +t0=$(now); kill -9 "$(pgrep -x resonanced)"; sleep 0.2 +if wait_reach 6; then ok "kill -9: launchd restarted the daemon in $(python3 -c "print(round($(now)-$t0,2))") s"; else bad "kill -9: daemon did not come back"; fi +sleep 4 +check "verify after the restart (tap + consent survive)" verify +resonance daemon stop >/dev/null; sleep 1 +check "SIGTERM stop is final (KeepAlive honours a clean exit)" bash -c '! pgrep -x resonanced' +check "socket removed on clean stop" bash -c '! ls "$TMPDIR"/resonance.sock' +resonance daemon start >/dev/null; check "daemon start serves again" wait_reach 6 + +echo "== sample rate and device changes (pilot playing)" +resonance output "BlackHole 2ch" >/dev/null; sleep 2 +tone_on; sleep 3 +for r in 44100 96000 48000; do + $AUDIODEV rate "BlackHole 2ch" $r >/dev/null; sleep 3 + check "rate $r: status follows" bash -c "resonance status | grep -q 'format $r Hz'" + check "rate $r: audio flows again" audible 8 +done +resonance output "BlackHole 16ch" >/dev/null; sleep 2 +$AUDIODEV default "Apple Virtual Sound Device"; sleep 2 +tone_off; tone_on; sleep 4 +check "pinned output, OS default moved: tap follows the default" audible 8 +tone_off +$AUDIODEV default "BlackHole 2ch"; resonance output auto >/dev/null; sleep 3 + +echo "== upgrade over a running install" +if [ -n "$UPGRADE" ]; then + tone_on; sleep 3; old=$(pgrep -x resonanced) + sudo rm -rf $APP && sudo cp -R "$UPGRADE" $APP && sudo chown -R "$USER" $APP + check "old daemon keeps serving while its bundle is replaced" bash -c "reach() { resonance status >/dev/null 2>&1; }; reach && kill -0 $old" + resonance daemon restart >/dev/null + check "restart picks up the new binary (pid changed)" bash -c "[ \"\$(pgrep -x resonanced)\" != \"$old\" ]" + check "new build keeps the capture grant (audio within 8 s)" audible 8 + tone_off +else + echo "SKIP no upgrade bundle given" +fi + +echo "== uninstall" +resonance daemon disable >/dev/null; resonance daemon uninstall >/dev/null +check "no resonanced process" bash -c '! pgrep -x resonanced' +check "plist removed" bash -c '! test -e ~/Library/LaunchAgents/com.ealtun21.resonanced.plist' +check "agent unloaded" bash -c '! launchctl print gui/$(id -u)/com.ealtun21.resonanced' +check "socket and pid file removed" bash -c '! ls "$TMPDIR"/resonance.sock "$TMPDIR"/resonanced.pid' +check "no leftover tap aggregate device" bash -c "! $AUDIODEV list | grep -i 'resonance'" +tone_off +echo; [ $fail = 0 ] && echo LIFECYCLE-PASS || echo LIFECYCLE-FAIL +exit $fail diff --git a/contrib/e2e/macos/vmctl.sh b/contrib/e2e/macos/vmctl.sh new file mode 100755 index 0000000..3cda4f7 --- /dev/null +++ b/contrib/e2e/macos/vmctl.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Drive a macOS e2e guest from the host: boot an overlay, run ssh, take screenshots, type keys, click. +# Everything lives in $LC_DIR (default ~/.cache/resonance-e2e/macos/lifecycle); the base image is only +# ever a backing file. Usage: vmctl.sh reset|boot|ssh|script|scp|shot|key|type|click|sync|lifecycle|kill (see README.md) +set -eu +H=${RESONANCE_E2E_HOME:-$HOME/.cache/resonance-e2e} +LC_DIR=${LC_DIR:-$H/macos/lifecycle} +PORT=${LC_PORT:-22722} +KEY=${RESONANCE_E2E_SSH_KEY:-$H/ssh/id_ed25519} +SSH_OPTS=(-i "$KEY" -p "$PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=8) +HERE=$(cd "$(dirname "$0")" && pwd) +QMP=$LC_DIR/qmp.sock + +reset() { # fresh overlay of the base image (discards all guest changes) + B=$H/macos/base + "$0" kill || true + rm -rf "$LC_DIR/vm" && mkdir -p "$LC_DIR/vm" + qemu-img create -q -f qcow2 -F qcow2 -b "$B/disk.qcow2" "$LC_DIR/vm/disk.qcow2" + for f in OpenCore.qcow2 OVMF_CODE.fd OVMF_VARS-1920x1080.fd; do cp "$B/$f" "$LC_DIR/vm/"; done + truncate -s 1M "$LC_DIR/vm/RecoveryImage.img" + printf '#!/usr/bin/quickemu --vm\nguest_os="macos"\ndisk_img="vm/disk.qcow2"\nimg="vm/RecoveryImage.img"\nmacos_release="sequoia"\ncpu_cores="16"\nram="10G"\n' >"$LC_DIR/vm.conf" +} + +case ${1:-} in +reset) reset ;; +boot) # headless, with a QMP socket for input events and screendump + cd "$LC_DIR" + quickemu --vm vm.conf --display none --ssh-port "$PORT" --extra_args "-qmp unix:$QMP,server,nowait" >boot.log 2>&1 + tail -3 boot.log ;; +ssh) shift; exec ssh "${SSH_OPTS[@]}" e2e@127.0.0.1 "$@" ;; +script) exec ssh "${SSH_OPTS[@]}" e2e@127.0.0.1 'PATH=$HOME/.local/bin:$HOME/.cargo/bin:$PATH bash -s' ;; # script on stdin +scp) shift; exec scp -q -i "$KEY" -P "$PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$@" ;; +shot) # shot [out.png]: QMP screendump -> png (full 1920x1080 unless LC_SCALE is set) + out=${2:-$LC_DIR/shot.png} + python3 -I "$HERE/vmgui.py" "$QMP" shot "$out" ;; +key) shift; python3 -I "$HERE/vmgui.py" "$QMP" key "$@" ;; +type) shift; python3 -I "$HERE/vmgui.py" "$QMP" type "$*" ;; +click) shift; python3 -I "$HERE/vmgui.py" "$QMP" click "$@" ;; +sync) # copy the working tree (tracked + untracked, not ignored) into ~/resonance in the guest + root=$(cd "$HERE/../../.." && pwd) + "$0" ssh 'mkdir -p $HOME/resonance' + (cd "$root" && git ls-files -z -c -o --exclude-standard | tar --null -T - -cf -) | + "$0" ssh 'tar -xmf - -C $HOME/resonance' ;; +lifecycle) # sync + release-build the .app + run the lifecycle matrix (about 15 min on a cold build) + "$0" sync + "$0" ssh 'rm -rf ~/build-prev; [ -d ~/build/Resonance.app ] && mv ~/build ~/build-prev; true' + "$0" ssh 'bash ~/resonance/contrib/e2e/macos/lifecycle/build-app.sh > ~/build-app.log 2>&1; tail -1 ~/build-app.log' + # install the previous build first when there is one, so the upgrade step really upgrades + "$0" ssh 'bash ~/resonance/contrib/e2e/macos/lifecycle/lifecycle.sh "$( [ -d ~/build-prev ] && echo ~/build-prev || echo ~/build )/Resonance.app" ~/build/Resonance.app' ;; +kill) # stop qemu + if [ -f "$LC_DIR/vm/vm.pid" ]; then kill "$(cat "$LC_DIR/vm/vm.pid")" 2>/dev/null || true; sleep 2; fi ;; +*) echo "usage: $0 reset|boot|ssh|script|scp|shot|key|type|click|sync|lifecycle|kill" >&2; exit 2 ;; +esac diff --git a/contrib/e2e/macos/vmgui.py b/contrib/e2e/macos/vmgui.py new file mode 100644 index 0000000..8228e43 --- /dev/null +++ b/contrib/e2e/macos/vmgui.py @@ -0,0 +1,94 @@ +"""Drive a QEMU guest's console over QMP: screenshot, keys, text, mouse. Stdlib only. + + vmgui.py QMP_SOCK shot OUT.png + vmgui.py QMP_SOCK key meta_l+spc ret (qcodes joined with + press together) + vmgui.py QMP_SOCK type 'some text' + vmgui.py QMP_SOCK click X Y [N] [right] (pixels of the 1920x1080 framebuffer) +""" +import json +import os +import socket +import sys +import time + +W, H = 1920, 1080 +SHIFTED = {'_': '-', ':': ';', '"': "'", '|': '\\', '>': '.', '<': ',', '(': '9', ')': '0', '*': '8', + '$': '4', '&': '7', '@': '2', '%': '5', '#': '3', '!': '1', '+': '=', '{': '[', '}': ']', + '?': '/', '^': '6', '~': '`'} +PLAIN = {' ': 'spc', '\\': 'backslash', '.': 'dot', ',': 'comma', '-': 'minus', ';': 'semicolon', + '/': 'slash', "'": 'apostrophe', '=': 'equal', '[': 'bracket_left', ']': 'bracket_right', + '`': 'grave_accent', '\n': 'ret', '\t': 'tab'} + + +class Qmp: + def __init__(self, path): + self.s = socket.socket(socket.AF_UNIX) + self.s.connect(path) + self.f = self.s.makefile('rw') + self.f.readline() + self.call('qmp_capabilities') + + def call(self, cmd, **args): + self.f.write(json.dumps({'execute': cmd, 'arguments': args}) + '\n') + self.f.flush() + while True: # skip async events + r = json.loads(self.f.readline()) + if 'return' in r or 'error' in r: + return r + + +def qcode(ch): + if ch in PLAIN: + return [PLAIN[ch]] + if ch in SHIFTED: + base = SHIFTED[ch] + return ['shift', PLAIN.get(base, base)] + if ch.isupper(): + return ['shift', ch.lower()] + return [ch] + + +def keys(q, codes, hold=0.03): + ev = [{'type': 'key', 'data': {'down': True, 'key': {'type': 'qcode', 'data': c}}} for c in codes] + q.call('input-send-event', events=ev) + time.sleep(hold) + q.call('input-send-event', events=[dict(e, data=dict(e['data'], down=False)) for e in reversed(ev)]) + time.sleep(hold) + + +def main(): + q = Qmp(sys.argv[1]) + cmd = sys.argv[2] + if cmd == 'shot': + raw = os.path.abspath(sys.argv[3]) + '.ppm' + r = q.call('screendump', filename=raw, format='ppm') + if 'error' in r: + sys.exit(r) + time.sleep(0.5) + try: + from PIL import Image + Image.open(raw).save(sys.argv[3]) + os.unlink(raw) + except ImportError: # no PIL: keep the ppm + print(raw) + elif cmd == 'key': + for chord in sys.argv[3:]: + keys(q, chord.split('+')) + elif cmd == 'type': + for ch in sys.argv[3]: + keys(q, qcode(ch)) + elif cmd == 'click': + x, y = int(sys.argv[3]), int(sys.argv[4]) + n = int(sys.argv[5]) if len(sys.argv) > 5 and sys.argv[5].isdigit() else 1 + btn = 'right' if 'right' in sys.argv[5:] else 'left' + ax = lambda a, v: {'type': 'abs', 'data': {'axis': a, 'value': v}} + q.call('input-send-event', events=[ax('x', int(x / W * 32767)), ax('y', int(y / H * 32767))]) + time.sleep(0.3) + for _ in range(n): + for down in (True, False): + q.call('input-send-event', events=[{'type': 'btn', 'data': {'down': down, 'button': btn}}]) + time.sleep(0.08) + time.sleep(0.15) + + +main() diff --git a/docs/superpowers/specs/2026-10-06-e2e-audio-test-infra-design.md b/docs/superpowers/specs/2026-10-06-e2e-audio-test-infra-design.md index 36b9e6d..b91aa0d 100644 --- a/docs/superpowers/specs/2026-10-06-e2e-audio-test-infra-design.md +++ b/docs/superpowers/specs/2026-10-06-e2e-audio-test-infra-design.md @@ -593,3 +593,51 @@ Windows scenario also saves `apo.log`. - Observed once under parallel load at 8 vCPUs: Windows `long-full-eq@96000x8` differed from the render at frame 3931392 (-13.2 dBFS). It passed twice alone at 16 vCPUs. +## 18. macOS lifecycle, driven through the guest desktop (2026-10-12) + +Setup and technique: `contrib/e2e/macos/README.md` ("Driving the guest desktop"). Run on the Intel macOS +15.8.1 guest (not Apple silicon hardware) with a release `.app` built by the product's own `build-app.sh`, +installed with `resonance daemon enable`; audio judged by `resonance verify` (pitch and response of real tones +through the live tap), not by ear. Measured unless marked inferred. + +Works: the launchd agent starts and serves within the call's 6 s settle; `kill -9` is back in 0.2-1.5 s with +audio flowing (stale socket handled); `daemon stop` is final (KeepAlive honours exit 0), also while a consent +dialog is pending; rate changes 44.1/96/48 kHz and an output switch rebuild in 0.7-2.3 s with `verify` PASS and +no resampling; a loaded EQ (+6 dB peak, -5 dB shelf) measured within 0.00 dB of the prediction at 7 frequencies +(closes the macOS live part of #42); replacing the bundle under a running daemon leaves it serving, and +`daemon restart` onto the new cert-signed build keeps the capture grant; uninstall removes the plist, process, +socket, pid file and leaves no aggregate device. + +Findings: + +1. **Pinned output + OS default change = silent loss of the EQ (fixed).** The tap is bound to the default + output's UID, but `run_streams` only rebuilt on a default change when no output was pinned. With a pin, the + default moved to another device, app audio went there, and `levels in` stayed -inf (measured); unpinned it + followed. The supervisor now rebinds the tap when the default leaves the tap's device. Test: + `tap_goes_stale_only_when_the_default_moves_off_its_device` plus the lifecycle step. +2. **Tray icon was a solid black square on the macOS menu bar (fixed).** The template image kept the PNG's + alpha (the dark tile is 83 % opaque), so the glyph vanished. Alpha now comes from the glyph's luminance + (21 % coverage); test `template_is_black_glyph_not_a_solid_tile`. +3. **False "macOS is most likely refusing system audio capture" warning on an idle system (fixed).** It fired + whenever the tap was silent for 5 s, even right after `kTCCServiceAudioCapture: ALLOWED`. It now requires + that the grant was not seen as allowed. +4. **Unbounded log growth (fixed).** `HAL tap IOProc: ...` was logged at info every 2 s into the launchd + stdout log (88 KB in 13 min, about 10 MB/day, never rotated); it is debug now. +5. **Every release upgrade re-prompts (not fixed, needs a signing decision).** `release.yml` builds with the + ad-hoc signature (inferred from its comment). Re-signing a cert-signed install ad-hoc and restarting + (measured) gave `kTCCServiceAudioCapture: not-determined`, the microphone dialog, then the system-audio + dialog; the daemon sat after "Process tap ready" until both were answered. Grants are one row per bundle id + that follows the last signature, so ad-hoc <-> cert switches and every ad-hoc cdhash change repeat it. A + stable identity for release builds is the fix; until then the dialogs are unavoidable per upgrade. +6. **Nothing is restored after a daemon crash or restart (design gap, not fixed).** After `kill -9`, preamp, + a 40 % effect and the pinned output were back at defaults (measured); only an output->profile mapping + restores state. The mapped path was not exercised here. +7. **GUI could not be exercised**: no Metal adapter in the VM (`RequestAdapterError(NotFound)`), so the egui GUI + and the tray's "Open UI" were not driven; the tray menu (Power, Daemon start/stop) was. +8. One transient: the first `verify` right after an output switch lost tones 5-7 (a 5.9 s gap between two + 2 s IOProc log lines); about 20 later switches and runs did not repeat it (inferred: VM scheduling, see + MAC-E2). +9. Cosmetic: macOS posts a "Background Items Added" notification per plist (re)write; six accumulated in this + session. `resonance-gui.lock`, `resonance-tray.lock/pid` and `resonanced.lock` stay in `$TMPDIR` after + uninstall (inert). +