Controlling the app
SkillDev toolsLaunch, configure, drive, screenshot and measure the running NullPlayer app. Use when asked to run / launch / start the app, click or drag a control, reproduce a defect on screen, "open skin X", "show me it working", set up a test scenario, capture a window, check that every window opens, stretches and closes, or hand the user a loaded interactive session. Covers every skin family (Classic, Original, Original-Metal, Winamp Modern .wal, Windows Media Player .wmz) and names the canonical test-data targets.
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Controlling the app skill
What this skill tells your AI
The instructions your AI receives, as published by ad-repo/nullplayer in skills/app-control/SKILL.md and read by ahel’s review.
Rule zero: the app under test is the local debug build, always
skills/app-control/scripts/launch.sh <skin> is the launch command (Route B); it runs
./scripts/kill_build_run.sh --debug, which is the build-and-run command for a launch with no skin
to pin. Not swift build, not Xcode, not a release build.
Everything here then operates on the binary it produced:
BIN=.build/arm64-apple-macosx/debug/NullPlayer # Intel: .build/x86_64-apple-macosx/debug/…
Never invoke the installed app. Not nullplayer, not open -a NullPlayer, not
/Applications/NullPlayer.app/Contents/MacOS/NullPlayer, not activate application "NullPlayer".
Its version is unknown, it is not what you changed, and a clean result from it is worthless.
This has teeth:
/usr/local/bin/nullplayeris a shim thatexecs/Applications/NullPlayer.app. Everynullplayer --cli …line inclitherefore runs the installed build. Read that skill's flags; ignore its invocations.- The installed app's defaults domain is
com.nullplayer.app; the debug binary's isNullPlayer. Both exist on this machine with divergent contents. NULLPLAYER_SKINandNULLPLAYER_PLAYare#if DEBUGonly (App/AppDelegate.swift:56,66). A release or installed binary ignores them silently.
Two consequences, stated as facts:
- A recipe that builds any other way is a bug in the recipe. Release exists only for a deliberate profiling measurement, which is out of scope here.
- There is exactly one defaults domain in this skill:
NullPlayer.com.nullplayer.appis never read or written, so nothing here can damage the user's real preferences.
Routing
| Route | Use when | Cost |
|---|---|---|
| A — Don't launch the GUI | The question is about a skin's scene, geometry, hit map, script, or any pure logic | seconds |
| B — Launch preconfigured, don't click | You need the running app in a specific mode / skin / playback state | one launch |
| C — Drive it yourself | The defect needs a click, drag, hover, or a view switch | one launch + CGEvents |
| D — Hand the user a loaded session | Judgment ("does it look right"), contextual menus, anything a synthetic right-click cannot do | interactive |
| E — Measure | Always. Every route ends here | — |
Not this skill: skin-screenshots is the gallery GIF sweep and nothing else.
live-ui-testing is how not to fool yourself about what you measured. testing is swift test.
cli is the headless command surface — read its flags, ignore its invocations (Rule zero).
Route A — don't launch the GUI
Applies when the answer is in a skin's scene, geometry, hit map or script rather than on screen.
- Pick the headless entry point for the family.
- Set the probe flag for the question you are asking.
- Read the named line type out of the output.
WMP_SKIN="$HOME/Library/Application Support/NullPlayer/WMPSkins/corona.wmz" \
WMP_RENDER_PROBE=all WMP_RENDER_HOST=playing \
swift test --filter WMPRenderDumpTests/testSweepsSkinOrCorpus 2>&1 | grep '^PROBE'
| Family | Entry point | Day-one flags | Full flag table |
|---|---|---|---|
.wmz | WMP_SKIN=<file or dir> swift test --filter WMPRenderDumpTests/testSweepsSkinOrCorpus | WMP_RENDER_PROBE, WMP_RENDER_CLICK, WMP_RENDER_HOST=playing | wmp-skin-guide/reference/harness.md |
.wal | WINAMP_MODERN_WAL=<file> swift test --filter WinampModernRenderDumpTests | WINAMP_MODERN_RENDER_PROBE | winamp-modern-skin-guide/reference/harness.md |
| anything else | swift test | — | testing |
WMP_SKIN takes a file or a directory, so one command sweeps the whole corpus. The probe
flags are not copied here; go to the owning reference.
The CLI is also Route A — servers, radio, casting and library queries need no GUI:
BIN=.build/arm64-apple-macosx/debug/NullPlayer # never `nullplayer`
"$BIN" --cli --list-libraries --source plex --json
Confirm it took: the probe printed the line type you asked for (PROBE, CLICK, HOVER), or
--json returned a non-empty array. A probe that prints nothing ran against nothing.
Route B — launch preconfigured, don't click
One command launches the debug build on any skin, playing, and verifies it:
skills/app-control/scripts/launch.sh corona # .wmz → -uiMode wmp
skills/app-control/scripts/launch.sh aquamp # .wsz → -uiMode classic
skills/app-control/scripts/launch.sh 2222-cPro__Bento # .wal → -uiMode winampModern
skills/app-control/scripts/launch.sh modern:NeonWave # Original submenu
skills/app-control/scripts/launch.sh "metal:Brushed Steel" # Original-Metal submenu
It prints one line — LAUNCH PASS: wmp skin 'corona' pid … log /tmp/np.log — or LAUNCH FAIL: …
and exits 1. Trust that line and nothing else; do not hand-roll a skin launch. Every other
way of doing it (defaults write recipes, NULLPLAYER_SKIN for a .wmz, a bare
./.build/debug/NullPlayer, kill_build_run.sh without --debug) has launched the wrong skin in
a past session, silently.
<skin>is a bare installed name (searched acrossSkins/,WinampModernSkins/,WMPSkins/),name.extto pin a family, or an absolute path. A name found in two families fails and lists both rather than guessing. A.wmzpath outsideWMPSkins/is imported via-wmpSkinPath.--no-playskipsNULLPLAYER_PLAY(default:audio-longplaying; an exportedNULLPLAYER_PLAYreplaces it).--log <path>(default/tmp/np.log). App arguments go after--; trace env vars are simply exported in front:WMP_SEEK_TRACE=1 skills/app-control/scripts/launch.sh corona -- -winampModernShowVisualization 1.- It quits any running NullPlayer first — including another session's. Say so before using it while someone else's run is up.
- Nothing to restore afterwards. Session restoration is disabled with the launch argument
-rememberStateEnabled NO, never adefaults write, so the saved Remember State is untouched and there is no restore trap to race. (The old recipes' trap ran the instant a non-interactiveread </dev/ttyfailed — restoring the previous skin andrememberStateEnabled=1while the app was still starting. That was the wrong-skin bug.)
Why each family is selected the way it is — only needed when changing launch.sh:
| Family | Mechanism | PASS means |
|---|---|---|
.wsz/.whsz | NULLPLAYER_SKIN=<path> (DEBUG only) | lastClassicSkinPath rewritten to that path (deleted first) |
.wal | -winampModernSkinPath <path> (DEBUG only) | log WinampModern surfaces [<file>.wal]: |
.wmz installed | defaults write wmpSkinName, wmpSkinViewID deleted | wmpSkinName is the name and the app re-wrote wmpSkinViewID (it only does once a scene renders) |
.wmz elsewhere | -wmpSkinPath <path> (imports it) | same |
| modern / metal | defaults write modernSkinName|metalSkinName | a ModernSkinLoader: Loaded skin log line whose path ends in the <name> folder (the quoted name is skin.json's meta.name, which differs for Bubblegum Retro, EmeraldForge, Sakura Minimal) / Loaded built-in metal skin '<name>' |
NULLPLAYER_SKIN is the classic loader: a .wmz there loads nothing and comes up unskinned
(440x170). Restoration, if left on, rewrites wmpSkinName from the saved state before the window
opens. launch.sh exists so neither has to be remembered.
Deleting wmpSkinViewID makes every .wmz launch a first launch. The user's second launch
onward starts the view walk at the persisted player and skips the views ahead of it, so a defect
that only shows "after a relaunch" never reproduces through launch.sh — W299 measured fine on nine
launches that way. Reproduce it by killing the app and relaunching the debug binary with
wmpSkinViewID left in place (./scripts/kill_build_run.sh --debug -- -uiMode wmp -rememberStateEnabled NO, with NULLPLAYER_PLAY set if the skin needs playback).
The mode names do not match the menu. -uiMode modern is the Original submenu;
-uiMode winampModern is the Modern submenu (App/PlayerUIMode.swift:33-39).
NULLPLAYER_PLAY takes audio and .cue only — mp3 m4a aac wav aiff aif flac ogg alac
(App/AppDelegate.swift:340,357). An .m3u or .mp4 there is dropped with no log line and reads
exactly like a playback bug. Video never goes through it, and Windows → Video Player is
inert until a video has been opened from a browser (App/WindowManager.swift:3205). Media
recipes (audio/video × local/streaming) are in reference/launch-recipes.md.
Launch rules that still apply to anything launch.sh does not cover:
- Redirect, never pipe.
kill_build_run.sh --log <path>writes the log; a pipe keeps the script attached. Live traces write to stderr, and a redirectedprintis block-buffered. - The front door un-throttles for you (
taskpolicy -B). A hand-rollednohupdoes not: the app inherits background QoS, timers defer and animation stalls. Confirm withps -o nice= -p <pid>. - The domain is
NullPlayer. Nevercom.nullplayer.app. - Servers / radio / casting: don't launch the GUI —
"$BIN" --cli …(Route A).
Route C — drive it yourself
Applies when the defect needs a click, drag, hover or a view switch. Build the tools once:
skills/app-control/scripts/build.sh.
- Launch via Route B.
- Get the window id and origin from
winhelper windows. - Get the control's coordinates from the subsystem's probe (Route A), never from a screenshot.
- Raise the app and drive one throwaway gesture — the first click on an inactive window is consumed activating it.
- Mark the log, act, read from the mark.
WH=skills/app-control/scripts/winhelper
PID=$(pgrep -x NullPlayer | head -1)
read -r WID _ X Y W H _ < <("$WH" windows --pid "$PID" --size 289x283) # the skin's own canvas
"$WH" raise "$PID" # exits non-zero unless it is frontmost
"$WH" click $((X+320)) $((Y+291)) # throwaway: activates the window
"$WH" drag $((X+320)) $((Y+291)) $((X+400)) $((Y+291)) $((X+500)) $((Y+291))
- Match the window by its size, never by
head -1. A.wmzwindow list can carry a second, transient row for the same app — a different id at a different origin — and a click computed from it lands on the desktop: the gesture posts, the log shows the hover/down repaints of nothing, and it reads exactly like a dead control. Two measurement runs on 2026-09-17 were thrown away to it, one of them a "0 redraws" rate that was really a pane that never opened. Pick the row whosew/hare the skin's own canvas ($WH windows --size 289x283, and--pidso the installed app's rows never match), and confirm the state you think you set from the subsystem's own trace before measuring anything against it.
| Verb | What it posts |
|---|---|
winhelper windows [--pid <n>] [--size <w>x<h>] | id layer x y w h alpha title, on-screen windows owned by NullPlayer, front to back |
winhelper raise <pid> | frontmost via System Events by unix id; exits non-zero unless that pid is frontmost afterwards |
winhelper park <pid> <title> <x> <y> | moves the window with that title to a top-left screen point and raises it, then reads the position back; non-zero if no window has that title or it landed elsewhere — use it before capture on a window that runs off the screen |
winhelper capture <id> <out.png> [--pid <n>] | the window's own content (screencapture -l), size-checked — see below |
winhelper capture-all <outdir> [--pid <n>] [--size <w>x<h>] | capture for every matching window, one PNG each; non-zero if any is refused |
winhelper capture-region <x> <y> <w> <h> <out.png> | the screen over a rect (screencapture -R), top-left points as windows — everything on top of it included, so raise the build first. The only way to see a .wmz/.wal window's drop shadow, which is a separate window; capture the window plus ~40 pt each side |
winhelper click <x> <y> | mouseMoved, then down/up with mouseEventClickState = 1 |
winhelper dblclick <x> <y> | two clicks, the second at clickState = 2 |
winhelper clickdiff <x> <y> [--pid <n>] [--size <w>x<h>] [--settle <s>] | the window-frame check: before rows, a click, a wait (1 s default), after rows, then one changed/gone/new line per window; exits 2 when nothing changed. dblclickdiff is the same with dblclick. --size filters only the before listing |
winhelper scroll <x> <y> <count> <delta> [line|precise] | count wheel events at one point; precise is a trackpad (points), line (the default) a mouse wheel (lines) |
winhelper move <x> <y> … | mouseMoved through the path, 250 ms apart |
winhelper drag <x> <y> … | press, leftMouseDragged through the path, release at the last point |
osascript menu.applescript mode|skin|load|list|current|family|closeaux <pid> … | the Skins / Windows menu verbs. skin picks a skin (switching family if needed), mode presses "Switch to …", load <sub> <path> answers "Load … Skin..."'s open panel (types into it, so the build is raised first), family names the ticked family |
osascript menu.applescript windowitems <pid> | one index|name|enabled|checked line per Windows-menu window toggle — block 1 minus Main Window, Debug Console and Recreate Windows (Debug), plus a .wal skin's own windows |
osascript menu.applescript toggle <pid> <index> <name> | presses (AXPress, menu unopened) Windows item index, erroring (exit non-zero, nothing clicked) if its name is no longer name |
winhelper screens | each display's visibleFrame as x y w h scale, in the same top-left points as windows |
- A skin window's drop shadow is never a row. Each
.wmz/.walwindow carries a click-through child titledNullPlayer.SkinShadow, 30 pt larger on every side.windows,capture-alland every script built on them leave it out;requireNullPlayerskips it too, so a press in that 30 pt ring is judged by the window really under it and refused.captureis the one verb that needs it:-lreturns a window with its children, so every skinned window comes back as a group, and the window's own rect is cropped out of it against its shadow row (cropped-from-group). - A press lands only on NullPlayer.
click,dblclick,clickdiff,scrolland the first point ofdragexit 1 and post nothing unless the frontmost window under that point belongs to NullPlayer. An empty lookup reads as 0 in shell arithmetic: on 2026-09-27 an uncheckedread … < <(winhelper windows | grep …)clicked the menu bar and dragged from the screen's top-left corner, and hung Finder and the Dock. Check the lookup anyway ([ -n "$X" ] || exit 1). clickStateis why clicks used to do nothing. An event posted without it arrivesclickCount == 0: any handler gating onclickCount == 1ignores it while the window still highlights. Bothclickanddragset it. Measured A/B on the same browser row: the pre-fix tool's two rapid clicks opened 0 windows and logged nothing;dblclickat the identical point opened the video window and loggedVideoPlayerView: Playing.- A hover is not a click with the buttons left out.
onMouseOver/onMouseOutfire on the edges between controls, so the path is the test — and the app must be frontmost, or a borderless window gets nomouseMovedat all. menu.applescriptcloses menus through Accessibility (AXCancel), never with Escape.key code 53goes to the frontmost app, so a menu opened on the background debug build stayed up, held it in menu tracking, and every later toggle silently did nothing; a verb that errors half-way closes its menus too. A NullPlayer row at a layer other than 0 inwinhelper windowsis an open menu — nothing driven while one is up can be trusted.menu.applescriptrequires a pid and resolvesfirst process whose unix id is <pid>. There is no name fallback:process "NullPlayer"is ambiguous whenever the installed build is also running, which is how it gets driven by accident.- A menu toggle is a user decision, and it outlives the process.
toggleandcloseaux(whichwindow-census.shruns first) write a.walskin's remembered window visibility, andlaunch.shturns off session restore but not that. So a window the last run closed stays closed on the next launch, and one it left open comes back. B154 was filed as "the equalizer is shown during load and then hidden". The hiding was the harness's owncloseaux. Before blaming the app for a window that appears or vanishes, check what the previous run's toggles left behind. - A contextual menu is not drivable. That is Route D.
capturerefuses a picture that is not the window.screencapture -lreturns a full-screen image for an off-screen or stale id, and the whole docked group for a window with attached windows (a 197x194 pt.wmzpane came back 950x890 pt, it plus two docked neighbours, 2026-09-24).capturechecks the pixel size against the window's points × scale; a group-sized image is cropped to the window and markedcropped-from-group; anything else exits non-zero. It retries three times, 400 ms apart, because a pane that is fading in or resizing changes size between the listing and the shot.-lsees the window regardless of occlusion, unlike-R, which photographs the screen.- A wheel gesture has two devices and a surface may read only one (W246).
.lineevents carry a line count and.pixelevents a precise, continuous delta in points — the trackpad's, and the only onehasPreciseScrollingDeltasis true for. A list that advances one row per event however hard you flick is reading the delta's sign; one that ignores a flick entirely may be reading the other unit. Post both before concluding anything, and remember a scroll is state the screen holds, not a log line — capture the window, do not grep for it. - A scroll position can be undone faster than you can capture it. WMP's playlist was pulled back onto the playing track by every host refresh, ~12 a second, so the gesture did land and the picture 80 ms later showed it had not. If a gesture seems not to take, capture immediately after it and again a second later: two different pictures mean something is fighting you, not that the event missed.
Confirm it took: the subsystem's live trace shows the gesture. A WMP_SEEK_TRACE=1 drag prints
one performSlider per point and exactly one commit; a commit per move is the W156 regression.
Route D — hand the user a loaded session
Applies to judgment ("does it look right"), contextual menus, and anything a synthetic gesture cannot do. The agent owns the process and the log; the user owns the mouse.
- Launch it yourself with
launch.sh(Route B) and wait forLAUNCH PASS. The user runs nothing. - Tell the user it is up, on which skin, and what to look at.
- End your turn. Do not block, do not sleep, do not background a watcher.
- Mark the log's line count. On the user's next message, read from that mark, answer, re-mark, end the turn.
Confirm it took: the LAUNCH PASS line.
Route E — measure
Every route ends here.
winhelper windowsfor the window id and geometry.- Capture the window, not the screen.
- Two captures separated in time, compared, for anything that should be changing.
WID=$("$WH" windows | awk -F'\t' '$2==0{print $1; exit}')
screencapture -o -x -l "$WID" /tmp/t1.png; sleep 6
screencapture -o -x -l "$WID" /tmp/t2.png
cmp -s /tmp/t1.png /tmp/t2.png && echo "IDENTICAL" || echo "DIFFER"
-
-l <windowid>captures the window's own content.-R <rect>captures whatever is on top, which is routinely your terminal. A conclusion drawn from a-Rcapture is worthless. -
On a docked window,
-lreturns the whole docked group, not the window you named. The image spans the union of every docked member, and its origin is the group's, not the window's — so a coordinate read off that capture is wrong by however far the window sits into the group. Check both dimensions: a stack docked vertically has the group's height and the window's width, so a width-only check passes while the capture is still the group and everyyyou read is wrong. Divide each capture dimension by 2 (retina) and compare with the rowwinhelper windowsgives for that id; if either disagrees, map back through the group origin — the smallestxandyamong the docked rows, which is not necessarily one window's corner:"$WH" windows | awk -F'\t' '$2==0 {if(gx==""||$3<gx)gx=$3; if(gy==""||$4<gy)gy=$4} END{print gx,gy}' # screen point for a capture pixel (px,py): x = gx + px/2 , y = gy + py/2Measured: main + Playlist docked,
winhelperreports the Playlist as344x145, the capture comes back688x580— 344 wide (agrees) and 290 tall (the group). -
Mark the log before acting and read from the mark. The startup log is thousands of lines of server chatter;
grep -o "^[^{]*"strips the JSON bodies. -
Take a control. One capture of a thing that should change proves nothing.
Confirm it took: you can name the two artefacts your conclusion rests on.
Playback snapshot — read audio and cast state from the running app
skills/app-control/scripts/playback-snapshot.sh [<pid>] makes the running debug build write
one snapshot and prints it. It is the instrument for "it says playing but nothing is heard",
"why didn't Play do anything after the cast", and any question about what the engine, the audio
graph or the cast session thinks right now — without an instrumented rebuild. lldb does not attach
to the ad-hoc-signed debug build, so this is the only way to read that state live.
snapshot at=2026-09-29T23:51:42Z pid=55912
engine state=stopped track='nil' index=-1/13 time=0.0
engine pipeline=local audioFile=false running=false player.playing=false player.sampleTime=nil crossfadeActive=false
engine volume=0.57 mainMixerOut=0.57 player.volume=1.00 eqBypass=false pitchRate=1.00
graph recovery=ready pendingIntent=nil needsReplacement=false retryScheduled=false
output engineDevice='MacBook Pro Speakers' systemDefault='MacBook Pro Speakers' selected=default format=48000Hz/2ch
level mainMixerPeak=n/a (engine not running)
cast session=nil state=nil currentCast=none isCasting=false routingActive=false anyActive=false
cast position=nil sessionPlaying=nil sonosRooms=0 localFileCastInProgress=false
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 124
- Forks
- 8
- Last commit
- Oct 2026
ahel review
K1binfo
installs-packages (in reference/test-data.md)
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
app-control- Source
- github.com/ad-repo/nullplayer
Related picks
Skill · wshobson
The pick for Pythonpython-pro
Skill · jeffallan
The pick for Pythonwrite-swift
Skill · emilkowalski
The pick for Swiftswift-concurrency-expert
Skill · davila7
The pick for Swiftteach
Skill · mattpocock
More in Dev toolsimplement
Skill · mattpocock
More in Dev tools