Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 43 additions & 0 deletions contrib/e2e/macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions contrib/e2e/macos/lifecycle/build-app.sh
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions contrib/e2e/macos/lifecycle/gui-run.sh
Original file line number Diff line number Diff line change
@@ -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/<uid>, 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" <<EOF
<?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>$L</string>
<key>ProgramArguments</key><array><string>/bin/bash</string><string>$HOME/.lc-exec.sh</string></array>
<key>StandardOutPath</key><string>$OUT</string><key>StandardErrorPath</key><string>$OUT</string>
<key>RunAtLoad</key><false/></dict></plist>
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")"
107 changes: 107 additions & 0 deletions contrib/e2e/macos/lifecycle/lifecycle.sh
Original file line number Diff line number Diff line change
@@ -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('<hh', v, v) for v in s))
E
launchctl bootout gui/$(id -u)/com.lc.tone 2>/dev/null
cat >/tmp/lc-tone.plist <<E
<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>Label</key><string>com.lc.tone</string>
<key>ProgramArguments</key><array><string>/bin/bash</string><string>-c</string><string>while :; do /usr/bin/afplay /tmp/lc-1k.wav; done</string></array>
<key>RunAtLoad</key><true/></dict></plist>
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
53 changes: 53 additions & 0 deletions contrib/e2e/macos/vmctl.sh
Original file line number Diff line number Diff line change
@@ -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
94 changes: 94 additions & 0 deletions contrib/e2e/macos/vmgui.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading